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 = "下载地址")
|
@Schema(description = "下载地址")
|
||||||
private String downloadUrl;
|
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) {
|
public List<ConvertResultItemVo> listHistory(Long userId) {
|
||||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.eq(FileResultEntity::getUserId, userId)
|
.eq(FileResultEntity::getUserId, userId)
|
||||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
.last("limit 200"))
|
.last("limit 200"));
|
||||||
.stream()
|
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||||
|
return entities.stream()
|
||||||
.map(entity -> {
|
.map(entity -> {
|
||||||
ConvertResultItemVo vo = new ConvertResultItemVo();
|
ConvertResultItemVo vo = new ConvertResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
@@ -215,11 +217,46 @@ public class ConvertRunService {
|
|||||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
|
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
|
||||||
|
vo.setTaskId(entity.getTaskId());
|
||||||
|
vo.setTaskStatus(task == null ? null : task.getStatus());
|
||||||
|
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
|
||||||
|
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
return vo;
|
return vo;
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量加载转换结果关联的任务信息(列裁剪,只取状态与时间) */
|
||||||
|
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
|
||||||
|
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||||
|
List<Long> taskIds = entities.stream()
|
||||||
|
.map(FileResultEntity::getTaskId)
|
||||||
|
.filter(id -> id != null && id > 0)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
if (taskIds.isEmpty()) {
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
|
.select(FileTaskEntity::getId,
|
||||||
|
FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt,
|
||||||
|
FileTaskEntity::getFinishedAt)
|
||||||
|
.in(FileTaskEntity::getId, taskIds))) {
|
||||||
|
if (task != null) {
|
||||||
|
taskMap.put(task.getId(), task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime t) {
|
||||||
|
return t == null ? null : t.toString();
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteHistory(Long resultId, Long userId) {
|
public void deleteHistory(Long resultId, Long userId) {
|
||||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||||
|
|||||||
+15
@@ -27,4 +27,19 @@ public class DedupeResultItemVo {
|
|||||||
|
|
||||||
@Schema(description = "结果文件大小(字节)")
|
@Schema(description = "结果文件大小(字节)")
|
||||||
private Long resultFileSize;
|
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) {
|
public List<DedupeResultItemVo> listHistory(Long userId) {
|
||||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getModuleType, "DEDUPE")
|
.eq(FileResultEntity::getModuleType, "DEDUPE")
|
||||||
.eq(FileResultEntity::getUserId, userId)
|
.eq(FileResultEntity::getUserId, userId)
|
||||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
.last("limit 50"))
|
.last("limit 50"));
|
||||||
.stream()
|
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||||
|
return entities.stream()
|
||||||
.map(entity -> {
|
.map(entity -> {
|
||||||
DedupeResultItemVo vo = new DedupeResultItemVo();
|
DedupeResultItemVo vo = new DedupeResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
@@ -403,11 +405,46 @@ public class DedupeRunService {
|
|||||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
|
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
|
||||||
|
vo.setTaskId(entity.getTaskId());
|
||||||
|
vo.setTaskStatus(task == null ? null : task.getStatus());
|
||||||
|
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
|
||||||
|
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
return vo;
|
return vo;
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量加载去重结果关联的任务信息(复用 appearancepatent 的列裁剪写法) */
|
||||||
|
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
|
||||||
|
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||||
|
List<Long> taskIds = entities.stream()
|
||||||
|
.map(FileResultEntity::getTaskId)
|
||||||
|
.filter(id -> id != null && id > 0)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
if (taskIds.isEmpty()) {
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
|
.select(FileTaskEntity::getId,
|
||||||
|
FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt,
|
||||||
|
FileTaskEntity::getFinishedAt)
|
||||||
|
.in(FileTaskEntity::getId, taskIds))) {
|
||||||
|
if (task != null) {
|
||||||
|
taskMap.put(task.getId(), task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime t) {
|
||||||
|
return t == null ? null : t.toString();
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteHistory(Long resultId, Long userId) {
|
public void deleteHistory(Long resultId, Long userId) {
|
||||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||||
|
|||||||
+10
@@ -78,4 +78,14 @@ public class DeleteBrandResultItemVo {
|
|||||||
|
|
||||||
@Schema(description = "由于列表来自 history 接口,增加所属任务的状态(RUNNING/SUCCESS/FAILED),协助前端准确定位执行中任务")
|
@Schema(description = "由于列表来自 history 接口,增加所属任务的状态(RUNNING/SUCCESS/FAILED),协助前端准确定位执行中任务")
|
||||||
private String taskStatus;
|
private String taskStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
|
||||||
|
*/
|
||||||
|
private String createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
|
||||||
|
*/
|
||||||
|
private String finishedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -293,6 +293,7 @@ public class DeleteBrandRunService {
|
|||||||
List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.select(FileTaskEntity::getId,
|
.select(FileTaskEntity::getId,
|
||||||
FileTaskEntity::getStatus,
|
FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt,
|
||||||
FileTaskEntity::getUpdatedAt,
|
FileTaskEntity::getUpdatedAt,
|
||||||
FileTaskEntity::getFinishedAt)
|
FileTaskEntity::getFinishedAt)
|
||||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||||
@@ -375,6 +376,9 @@ public class DeleteBrandRunService {
|
|||||||
// 真实补充
|
// 真实补充
|
||||||
if (entity.getTaskId() != null) {
|
if (entity.getTaskId() != null) {
|
||||||
item.setTaskStatus(statusByTaskId.get(entity.getTaskId()));
|
item.setTaskStatus(statusByTaskId.get(entity.getTaskId()));
|
||||||
|
FileTaskEntity taskEntity = taskById.get(entity.getTaskId());
|
||||||
|
item.setCreatedAt(fmt(taskEntity == null ? null : taskEntity.getCreatedAt()));
|
||||||
|
item.setFinishedAt(fmt(taskEntity == null ? null : taskEntity.getFinishedAt()));
|
||||||
List<DeleteBrandResultItemVo> taskItems = null;
|
List<DeleteBrandResultItemVo> taskItems = null;
|
||||||
if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) {
|
if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) {
|
||||||
for (DeleteBrandResultItemVo candidate : taskItems) {
|
for (DeleteBrandResultItemVo candidate : taskItems) {
|
||||||
@@ -1234,6 +1238,7 @@ public class DeleteBrandRunService {
|
|||||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.select(FileTaskEntity::getId,
|
.select(FileTaskEntity::getId,
|
||||||
FileTaskEntity::getStatus,
|
FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt,
|
||||||
FileTaskEntity::getUpdatedAt,
|
FileTaskEntity::getUpdatedAt,
|
||||||
FileTaskEntity::getFinishedAt)
|
FileTaskEntity::getFinishedAt)
|
||||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||||
@@ -1282,6 +1287,10 @@ public class DeleteBrandRunService {
|
|||||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime t) {
|
||||||
|
return t == null ? null : t.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NEVER)
|
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NEVER)
|
||||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
|
|||||||
+10
@@ -62,4 +62,14 @@ public class PriceTrackResultItemVo {
|
|||||||
|
|
||||||
@Schema(description = "所属轮次,1-based", example = "2")
|
@Schema(description = "所属轮次,1-based", example = "2")
|
||||||
private Integer roundIndex;
|
private Integer roundIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
|
||||||
|
*/
|
||||||
|
private String createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
|
||||||
|
*/
|
||||||
|
private String finishedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-9
@@ -142,7 +142,7 @@ public class PriceTrackTaskService {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -151,7 +151,8 @@ public class PriceTrackTaskService {
|
|||||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||||
int end = Math.min(start + batchSize, taskIds.size());
|
int end = Math.min(start + batchSize, taskIds.size());
|
||||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
.select(FileTaskEntity::getId, FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
|
||||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||||
}
|
}
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -208,16 +209,16 @@ public class PriceTrackTaskService {
|
|||||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||||
List<Long> taskIds = entities.stream()
|
List<Long> taskIds = entities.stream()
|
||||||
.map(FileResultEntity::getTaskId)
|
.map(FileResultEntity::getTaskId)
|
||||||
.filter(id -> id != null && id > 0)
|
.filter(id -> id != null && id > 0)
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
|
||||||
for (FileTaskEntity t : tasks) {
|
for (FileTaskEntity t : tasks) {
|
||||||
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
if (t != null) taskById.put(t.getId(), t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
long tasksLoadedAt = System.nanoTime();
|
long tasksLoadedAt = System.nanoTime();
|
||||||
@@ -229,14 +230,14 @@ public class PriceTrackTaskService {
|
|||||||
long jobsLoadedAt = System.nanoTime();
|
long jobsLoadedAt = System.nanoTime();
|
||||||
List<PriceTrackResultItemVo> items = new ArrayList<>();
|
List<PriceTrackResultItemVo> items = new ArrayList<>();
|
||||||
for (FileResultEntity entity : entities) {
|
for (FileResultEntity entity : entities) {
|
||||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||||
}
|
}
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
long finishedAt = System.nanoTime();
|
long finishedAt = System.nanoTime();
|
||||||
log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||||
userId,
|
userId,
|
||||||
entities.size(),
|
entities.size(),
|
||||||
statusByTaskId.size(),
|
taskById.size(),
|
||||||
jobMap.size(),
|
jobMap.size(),
|
||||||
elapsedMs(startedAt, finishedAt),
|
elapsedMs(startedAt, finishedAt),
|
||||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||||
@@ -1720,16 +1721,32 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
// 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceTrackResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
|
||||||
|
TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
|
PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
vo.setTaskId(entity.getTaskId());
|
vo.setTaskId(entity.getTaskId());
|
||||||
vo.setShopName(entity.getSourceFilename());
|
vo.setShopName(entity.getSourceFilename());
|
||||||
vo.setShopId(entity.getSourceFileUrl());
|
vo.setShopId(entity.getSourceFileUrl());
|
||||||
vo.setTaskStatus(taskStatus);
|
vo.setTaskStatus(taskStatus);
|
||||||
|
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
|
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
|
||||||
vo.setSuccess(ok);
|
vo.setSuccess(ok);
|
||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
@@ -1767,7 +1784,7 @@ public class PriceTrackTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.orderByAsc(FileResultEntity::getId));
|
.orderByAsc(FileResultEntity::getId));
|
||||||
for (FileResultEntity fr : rows) {
|
for (FileResultEntity fr : rows) {
|
||||||
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
|
detail.getItems().add(toHistoryItemVo(fr, task));
|
||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -79,4 +79,12 @@ public class ProductRiskResultItemVo {
|
|||||||
@JsonAlias("skip_asin_details_by_country")
|
@JsonAlias("skip_asin_details_by_country")
|
||||||
@JsonProperty("skipAsinDetailsByCountry")
|
@JsonProperty("skipAsinDetailsByCountry")
|
||||||
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonProperty("createdAt")
|
||||||
|
@Schema(description = "任务创建时间(biz_file_task.created_at,格式化字符串)")
|
||||||
|
private String createdAt;
|
||||||
|
|
||||||
|
@JsonProperty("finishedAt")
|
||||||
|
@Schema(description = "任务结束时间(biz_file_task.finished_at,未结束为 null)")
|
||||||
|
private String finishedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-9
@@ -133,7 +133,7 @@ public class ProductRiskTaskService {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -142,7 +142,8 @@ public class ProductRiskTaskService {
|
|||||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||||
int end = Math.min(start + batchSize, taskIds.size());
|
int end = Math.min(start + batchSize, taskIds.size());
|
||||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
.select(FileTaskEntity::getId, FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
|
||||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||||
}
|
}
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -202,17 +203,17 @@ public class ProductRiskTaskService {
|
|||||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||||
List<Long> taskIds = entities.stream()
|
List<Long> taskIds = entities.stream()
|
||||||
.map(FileResultEntity::getTaskId)
|
.map(FileResultEntity::getTaskId)
|
||||||
.filter(id -> id != null && id > 0)
|
.filter(id -> id != null && id > 0)
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
|
||||||
for (FileTaskEntity t : tasks) {
|
for (FileTaskEntity t : tasks) {
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
statusByTaskId.put(t.getId(), t.getStatus());
|
taskById.put(t.getId(), t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,14 +226,14 @@ public class ProductRiskTaskService {
|
|||||||
long jobsLoadedAt = System.nanoTime();
|
long jobsLoadedAt = System.nanoTime();
|
||||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||||
for (FileResultEntity entity : entities) {
|
for (FileResultEntity entity : entities) {
|
||||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||||
}
|
}
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
long finishedAt = System.nanoTime();
|
long finishedAt = System.nanoTime();
|
||||||
log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||||
userId,
|
userId,
|
||||||
entities.size(),
|
entities.size(),
|
||||||
statusByTaskId.size(),
|
taskById.size(),
|
||||||
jobMap.size(),
|
jobMap.size(),
|
||||||
elapsedMs(startedAt, finishedAt),
|
elapsedMs(startedAt, finishedAt),
|
||||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||||
@@ -999,7 +1000,7 @@ public class ProductRiskTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.orderByAsc(FileResultEntity::getId));
|
.orderByAsc(FileResultEntity::getId));
|
||||||
for (FileResultEntity fr : rows) {
|
for (FileResultEntity fr : rows) {
|
||||||
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
|
detail.getItems().add(toHistoryItemVo(fr, task));
|
||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -1048,16 +1049,32 @@ public class ProductRiskTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
// 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
|
||||||
|
TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
vo.setTaskId(entity.getTaskId());
|
vo.setTaskId(entity.getTaskId());
|
||||||
vo.setShopName(entity.getSourceFilename());
|
vo.setShopName(entity.getSourceFilename());
|
||||||
vo.setShopId(entity.getSourceFileUrl());
|
vo.setShopId(entity.getSourceFileUrl());
|
||||||
vo.setTaskStatus(taskStatus);
|
vo.setTaskStatus(taskStatus);
|
||||||
|
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
|
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
|
||||||
vo.setSuccess(ok);
|
vo.setSuccess(ok);
|
||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
|
|||||||
+26
-9
@@ -168,7 +168,7 @@ public class ShopMatchTaskService {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -177,7 +177,8 @@ public class ShopMatchTaskService {
|
|||||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||||
int end = Math.min(start + batchSize, taskIds.size());
|
int end = Math.min(start + batchSize, taskIds.size());
|
||||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
.select(FileTaskEntity::getId, FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
|
||||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||||
}
|
}
|
||||||
return tasks;
|
return tasks;
|
||||||
@@ -237,16 +238,16 @@ public class ShopMatchTaskService {
|
|||||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||||
List<Long> taskIds = entities.stream()
|
List<Long> taskIds = entities.stream()
|
||||||
.map(FileResultEntity::getTaskId)
|
.map(FileResultEntity::getTaskId)
|
||||||
.filter(id -> id != null && id > 0)
|
.filter(id -> id != null && id > 0)
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) {
|
for (FileTaskEntity task : selectTaskHistoryFieldsByIdsInBatches(taskIds)) {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
statusByTaskId.put(task.getId(), task.getStatus());
|
taskById.put(task.getId(), task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,14 +260,14 @@ public class ShopMatchTaskService {
|
|||||||
long jobsLoadedAt = System.nanoTime();
|
long jobsLoadedAt = System.nanoTime();
|
||||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||||
for (FileResultEntity entity : entities) {
|
for (FileResultEntity entity : entities) {
|
||||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||||
}
|
}
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
long finishedAt = System.nanoTime();
|
long finishedAt = System.nanoTime();
|
||||||
log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||||
userId,
|
userId,
|
||||||
entities.size(),
|
entities.size(),
|
||||||
statusByTaskId.size(),
|
taskById.size(),
|
||||||
jobMap.size(),
|
jobMap.size(),
|
||||||
elapsedMs(startedAt, finishedAt),
|
elapsedMs(startedAt, finishedAt),
|
||||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||||
@@ -963,7 +964,7 @@ public class ShopMatchTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.orderByAsc(FileResultEntity::getId));
|
.orderByAsc(FileResultEntity::getId));
|
||||||
for (FileResultEntity row : rows) {
|
for (FileResultEntity row : rows) {
|
||||||
detail.getItems().add(toHistoryItemVo(row, task.getStatus()));
|
detail.getItems().add(toHistoryItemVo(row, task));
|
||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -1031,16 +1032,32 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
// 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
|
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductRiskResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
|
||||||
|
TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||||
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
vo.setTaskId(entity.getTaskId());
|
vo.setTaskId(entity.getTaskId());
|
||||||
vo.setShopName(entity.getSourceFilename());
|
vo.setShopName(entity.getSourceFilename());
|
||||||
vo.setShopId(entity.getSourceFileUrl());
|
vo.setShopId(entity.getSourceFileUrl());
|
||||||
vo.setTaskStatus(taskStatus);
|
vo.setTaskStatus(taskStatus);
|
||||||
|
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
boolean success = entity.getSuccess() != null && entity.getSuccess() == 1;
|
boolean success = entity.getSuccess() != null && entity.getSuccess() == 1;
|
||||||
vo.setSuccess(success);
|
vo.setSuccess(success);
|
||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
|
|||||||
+15
@@ -35,4 +35,19 @@ public class SplitResultItemVo {
|
|||||||
|
|
||||||
@Schema(description = "压缩包内文件列表")
|
@Schema(description = "压缩包内文件列表")
|
||||||
private List<SplitArchiveEntryVo> entries;
|
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.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
@@ -161,12 +162,14 @@ public class SplitRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<SplitResultItemVo> listHistory(Long userId) {
|
public List<SplitResultItemVo> listHistory(Long userId) {
|
||||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.eq(FileResultEntity::getUserId, userId)
|
.eq(FileResultEntity::getUserId, userId)
|
||||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
.last("limit 100"))
|
.last("limit 100"));
|
||||||
.stream()
|
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||||
|
return entities.stream()
|
||||||
.map(entity -> {
|
.map(entity -> {
|
||||||
SplitResultItemVo vo = new SplitResultItemVo();
|
SplitResultItemVo vo = new SplitResultItemVo();
|
||||||
vo.setResultId(entity.getId());
|
vo.setResultId(entity.getId());
|
||||||
@@ -178,11 +181,46 @@ public class SplitRunService {
|
|||||||
vo.setError(entity.getErrorMessage());
|
vo.setError(entity.getErrorMessage());
|
||||||
vo.setEntryCount(0);
|
vo.setEntryCount(0);
|
||||||
vo.setEntries(new ArrayList<>());
|
vo.setEntries(new ArrayList<>());
|
||||||
|
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
|
||||||
|
vo.setTaskId(entity.getTaskId());
|
||||||
|
vo.setTaskStatus(task == null ? null : task.getStatus());
|
||||||
|
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
|
||||||
|
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
|
||||||
|
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
|
||||||
return vo;
|
return vo;
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量加载拆分结果关联的任务信息(列裁剪,只取状态与时间) */
|
||||||
|
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
|
||||||
|
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||||
|
List<Long> taskIds = entities.stream()
|
||||||
|
.map(FileResultEntity::getTaskId)
|
||||||
|
.filter(id -> id != null && id > 0)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
if (taskIds.isEmpty()) {
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
|
.select(FileTaskEntity::getId,
|
||||||
|
FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getCreatedAt,
|
||||||
|
FileTaskEntity::getFinishedAt)
|
||||||
|
.in(FileTaskEntity::getId, taskIds))) {
|
||||||
|
if (task != null) {
|
||||||
|
taskMap.put(task.getId(), task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return taskMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime t) {
|
||||||
|
return t == null ? null : t.toString();
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteHistory(Long resultId, Long userId) {
|
public void deleteHistory(Long resultId, Long userId) {
|
||||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||||
|
|||||||
@@ -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 { createApp } from 'vue'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import '@/styles/main.css'
|
import '@/styles/main.css'
|
||||||
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
|
|
||||||
|
|
||||||
createApp(ImageVideoPage).use(ElementPlus).mount('#app')
|
import App from '@/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数富AI 前端统一入口(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>
|
<template>
|
||||||
<div class="page-shell module-page amazon-console">
|
<div
|
||||||
<AmazonTopBar />
|
class="page-shell module-page amazon-console"
|
||||||
|
:style="{ '--scroll-color': currentGroup?.color ?? '#2dd4bf' }"
|
||||||
|
>
|
||||||
|
<AmazonTopBar :active-cat="activeCat" @change="onCatChange" />
|
||||||
|
|
||||||
<main class="console-body">
|
<main class="console-body">
|
||||||
<!-- Hero 宣传区 -->
|
<!-- Hero(仅前端工具分类显示,对齐 gemini-code 主页) -->
|
||||||
<section class="hero">
|
<section v-if="currentGroup && currentGroup.key === 'front'" class="hero">
|
||||||
<div class="hero-inner">
|
<div class="hero-left">
|
||||||
<div class="hero-left">
|
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
|
||||||
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
|
<h1 class="hero-title">让亚马逊运营,更清晰高效</h1>
|
||||||
<h1 class="hero-title">让亚马逊运营,更清晰高效</h1>
|
<p class="hero-sub">
|
||||||
<p class="hero-sub">
|
从采集、去重、查品到上架跟价,20 个工具按「前端 · 运营 · 后勤」三线组织,每条工具一句话讲清用途,标准流程一步不缺。
|
||||||
从采集、去重、查品到上架跟价,20 个工具按「前端 · 运营 · 后勤」三线组织,每条工具一句话讲清用途,标准流程一步不缺。
|
</p>
|
||||||
</p>
|
<div class="hero-stats">
|
||||||
<div class="hero-stats">
|
<div class="stat">
|
||||||
<div class="stat">
|
<strong style="color: #2dd4bf">20</strong>
|
||||||
<strong style="color: #2dd4bf">20</strong>
|
<span>个工具</span>
|
||||||
<span>个工具</span>
|
</div>
|
||||||
</div>
|
<div class="stat">
|
||||||
<div class="stat">
|
<strong style="color: #60a5fa">3</strong>
|
||||||
<strong style="color: #60a5fa">3</strong>
|
<span>大分类</span>
|
||||||
<span>大分类</span>
|
</div>
|
||||||
</div>
|
<div class="stat">
|
||||||
<div class="stat">
|
<strong style="color: #f59e0b">8</strong>
|
||||||
<strong style="color: #f59e0b">8</strong>
|
<span>前端标准流程步骤</span>
|
||||||
<span>前端标准流程步骤</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hero-card">
|
<div class="hero-card">
|
||||||
|
<div>
|
||||||
<div class="hero-card-head">
|
<div class="hero-card-head">
|
||||||
<div class="hero-card-dots">
|
<div class="hero-card-dots">
|
||||||
<i class="dot dot--teal"></i>
|
<i class="dot dot--teal"></i>
|
||||||
<i class="dot dot--blue"></i>
|
<i class="dot dot--blue"></i>
|
||||||
<i class="dot dot--brand"></i>
|
<i class="dot dot--brand"></i>
|
||||||
<span class="dots-line"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="hero-card-caption">数富AI · 运营工作台</span>
|
<span class="hero-card-caption">数富AI · 运营工作台</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="hero-card-title">让每一次运营动作,<br />都有清晰的工作流。</h2>
|
<h2 class="hero-card-title">让每一次运营动作,<br />都有清晰的工作流。</h2>
|
||||||
<div class="hero-card-divider"></div>
|
<div class="hero-card-divider"></div>
|
||||||
<p class="hero-card-sub">内置完整使用教程,从软件配置到运营上架,每一步流程清晰可查。</p>
|
<p class="hero-card-sub">内置完整使用教程,从软件配置到运营上架,</p>
|
||||||
<button type="button" class="hero-card-btn" @click="scrollToTools">查看工具</button>
|
</div>
|
||||||
|
<div class="hero-card-btn-row">
|
||||||
|
<span class="hero-card-sub">每一步流程清晰可查。</span>
|
||||||
|
<button class="hero-dl" type="button" @click="onDownloadClick">
|
||||||
|
<span>📥</span>
|
||||||
|
<span>立即下载教程</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 三组工具:标题 + 标准工作流 + 卡片墙 -->
|
<!-- 当前分类:标准工作流 + 卡片墙(分类切换式,同 gemini-code) -->
|
||||||
<section v-for="group in visibleGroups" :key="group.key" class="tool-group" :id="`group-${group.key}`">
|
<section v-if="currentGroup" class="tool-group" :id="`group-${currentGroup.key}`">
|
||||||
<header class="group-head">
|
<section v-if="visibleWorkflow(currentGroup)?.length" class="workflow-panel">
|
||||||
<i class="group-dot" :style="{ background: group.color }"></i>
|
|
||||||
<span class="group-name">{{ group.name }}</span>
|
|
||||||
<span class="group-desc">{{ group.desc }}</span>
|
|
||||||
<span class="group-count">{{ group.tools.length }} 个工具</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 标准工作流时间线 -->
|
|
||||||
<section v-if="visibleWorkflow(group)?.length" class="workflow-panel">
|
|
||||||
<div class="workflow-head">
|
<div class="workflow-head">
|
||||||
<span class="workflow-title">{{ group.workflowTitle }}</span>
|
<span class="workflow-title">{{ currentGroup.workflowTitle }}</span>
|
||||||
<span class="workflow-badge" :style="{ color: group.color }">{{ group.workflowBadge }}</span>
|
<span class="workflow-badge">{{ currentGroup.workflowBadge }}</span>
|
||||||
<span class="workflow-hint">{{ group.workflowHint }}</span>
|
<span class="workflow-hint">{{ currentGroup.workflowHint }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ol class="workflow-steps">
|
<ol class="workflow-steps">
|
||||||
<li
|
<template v-for="(step, index) in visibleWorkflow(currentGroup)" :key="`${currentGroup.key}-${index}`">
|
||||||
v-for="(step, index) in visibleWorkflow(group)"
|
<li
|
||||||
:key="`${group.key}-${index}`"
|
class="workflow-step"
|
||||||
class="workflow-step"
|
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
|
||||||
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
|
:style="{ '--gc': currentGroup.color }"
|
||||||
@click="onWorkflowStepClick(step)"
|
@click="onWorkflowStepClick(step)"
|
||||||
>
|
>
|
||||||
<span class="step-circle" :style="{ background: group.weak, color: group.color }">{{ index + 1 }}</span>
|
<span class="step-circle" :style="circleStyle(currentGroup)">{{ index + 1 }}</span>
|
||||||
<span class="step-name">{{ step.name }}</span>
|
<span class="step-name">{{ step.name }}</span>
|
||||||
<span class="step-desc">{{ step.desc }}</span>
|
<span class="step-desc">{{ step.desc }}</span>
|
||||||
<span v-if="index < visibleWorkflow(group).length - 1" class="step-line" :style="{ background: group.weak }"></span>
|
</li>
|
||||||
</li>
|
<span v-if="index < visibleWorkflow(currentGroup).length - 1" class="step-line" :style="lineStyle()"></span>
|
||||||
|
</template>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 卡片墙 -->
|
<header class="group-head">
|
||||||
|
<i class="group-dot" :style="{ background: currentGroup.color }"></i>
|
||||||
|
<span class="group-name">{{ currentGroup.name }}</span>
|
||||||
|
<span class="group-desc">{{ currentGroup.desc }}</span>
|
||||||
|
<span class="group-count">{{ currentGroup.tools.length }} 个工具</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<template v-for="tool in group.tools" :key="tool.id">
|
<template v-for="tool in currentGroup.tools" :key="tool.id">
|
||||||
<a v-if="resolveHref(tool.href)" :href="resolveHref(tool.href)!" class="tool-card">
|
<router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)">
|
||||||
<div class="tool-card-top">
|
<div class="tool-card-top">
|
||||||
<ToolIcon :name="tool.id" />
|
<ToolIcon :name="tool.id" />
|
||||||
<span v-if="tool.tag" class="tool-tag" :style="{ color: group.color }">{{ tool.tag }}</span>
|
<span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card-name">{{ tool.name }}</div>
|
<div class="tool-card-name">{{ tool.name }}</div>
|
||||||
<div class="tool-card-desc">{{ tool.desc }}</div>
|
<div class="tool-card-desc">{{ tool.desc }}</div>
|
||||||
<div class="tool-card-go">打开工具 →</div>
|
<div class="tool-card-go">打开工具 →</div>
|
||||||
</a>
|
</router-link>
|
||||||
|
|
||||||
<button v-else type="button" class="tool-card tool-card--disabled" @click="onPendingClick(tool)">
|
<button v-else type="button" class="tool-card tool-card--disabled" :style="cardStyle(currentGroup)" @click="onPendingClick(tool)">
|
||||||
<div class="tool-card-top">
|
<div class="tool-card-top">
|
||||||
<ToolIcon :name="tool.id" />
|
<ToolIcon :name="tool.id" />
|
||||||
<span v-if="tool.tag" class="tool-tag" :style="{ color: group.color }">{{ tool.tag }}</span>
|
<span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card-name">{{ tool.name }}</div>
|
<div class="tool-card-name">{{ tool.name }}</div>
|
||||||
<div class="tool-card-desc">{{ tool.desc }}</div>
|
<div class="tool-card-desc">{{ tool.desc }}</div>
|
||||||
@@ -112,25 +119,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
|
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
|
||||||
import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
|
import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
|
||||||
import ToolIcon from '@/pages/amazon/components/ToolIcon.vue'
|
import ToolIcon from '@/pages/amazon/components/ToolIcon.vue'
|
||||||
|
import { TUTORIAL_DOWNLOAD_URL } from '@/pages/amazon/download-btn'
|
||||||
import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
|
import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
|
||||||
import type { ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog'
|
import type { ToolGroup, ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog'
|
||||||
import { resolvePageHref } from '@/shared/page-prefix'
|
import { resolvePageHref } from '@/shared/page-prefix'
|
||||||
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const allowedKeys = ref<string[]>([])
|
const allowedKeys = ref<string[]>([])
|
||||||
const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
||||||
/** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */
|
/** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */
|
||||||
const permissionUnavailable = ref(false)
|
const permissionUnavailable = ref(false)
|
||||||
const statusText = ref('')
|
const statusText = ref('')
|
||||||
const statusType = ref<'normal' | 'error'>('normal')
|
const statusType = ref<'normal' | 'error'>('normal')
|
||||||
|
const activeCat = ref<'front' | 'ops' | 'logi'>('front')
|
||||||
let statusTimer: number | undefined
|
let statusTimer: number | undefined
|
||||||
|
|
||||||
/** 链接本地化:dev 预览去掉 /new_web_source 前缀;/brand、/web_source/* 等旧静态页仅桌面端存在 */
|
const currentGroup = computed(() => visibleGroups.value.find((g) => g.key === activeCat.value) ?? visibleGroups.value[0])
|
||||||
|
|
||||||
|
/** 链接归一:/new_web_source/xxx.html → /xxx(SPA 路径,见 shared/page-prefix) */
|
||||||
function resolveHref(rawHref?: string): string {
|
function resolveHref(rawHref?: string): string {
|
||||||
return resolvePageHref(rawHref)
|
return resolvePageHref(rawHref)
|
||||||
}
|
}
|
||||||
@@ -145,6 +160,20 @@ function stepHref(toolId?: string) {
|
|||||||
return resolveHref(TOOL_MAP.get(toolId)?.href ?? '')
|
return resolveHref(TOOL_MAP.get(toolId)?.href ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卡片/tag:weak 弱底 + 分类色文字(同 gemini-code,无核心特判) */
|
||||||
|
function tagStyle(group: ToolGroup) {
|
||||||
|
return { background: group.weak, color: group.color }
|
||||||
|
}
|
||||||
|
function circleStyle(group: ToolGroup) {
|
||||||
|
return { background: group.weak, color: group.color }
|
||||||
|
}
|
||||||
|
function lineStyle() {
|
||||||
|
return { background: '#4C5A75' }
|
||||||
|
}
|
||||||
|
function cardStyle(group: ToolGroup) {
|
||||||
|
return { '--gc': group.color } as Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
function onWorkflowStepClick(step: WorkflowStep) {
|
function onWorkflowStepClick(step: WorkflowStep) {
|
||||||
if (!step.toolId) {
|
if (!step.toolId) {
|
||||||
return
|
return
|
||||||
@@ -152,7 +181,7 @@ function onWorkflowStepClick(step: WorkflowStep) {
|
|||||||
const target = TOOL_MAP.get(step.toolId)
|
const target = TOOL_MAP.get(step.toolId)
|
||||||
const href = resolveHref(target?.href ?? '')
|
const href = resolveHref(target?.href ?? '')
|
||||||
if (href) {
|
if (href) {
|
||||||
window.location.href = href
|
router.push(href)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
|
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
|
||||||
@@ -167,31 +196,46 @@ function onPendingClick(tool: ToolInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSoon(name: string) {
|
function showSoon(name: string) {
|
||||||
statusText.value = `${name}暂未开通,敬请期待`
|
toast(`${name}暂未开通,敬请期待`, 'normal')
|
||||||
statusType.value = 'normal'
|
}
|
||||||
|
|
||||||
|
function toast(msg: string, type: 'normal' | 'error' = 'normal') {
|
||||||
|
statusText.value = msg
|
||||||
|
statusType.value = type
|
||||||
if (statusTimer) {
|
if (statusTimer) {
|
||||||
window.clearTimeout(statusTimer)
|
window.clearTimeout(statusTimer)
|
||||||
}
|
}
|
||||||
statusTimer = window.setTimeout(() => {
|
statusTimer = window.setTimeout(() => {
|
||||||
statusText.value = ''
|
statusText.value = ''
|
||||||
}, 2200)
|
}, 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTools() {
|
async function onDownloadClick() {
|
||||||
void nextTick(() => {
|
// 教程客户端压缩包取自 MinIO 公开直链(client/tutorial/,见 download-btn.ts)
|
||||||
document.querySelector('.card-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
const api = getPywebviewApi()
|
||||||
})
|
if (api?.open_external_url) {
|
||||||
}
|
// 桌面端:交系统默认浏览器下载,可看到下载进度
|
||||||
|
try {
|
||||||
function scrollToHashGroup() {
|
const res = await api.open_external_url(TUTORIAL_DOWNLOAD_URL)
|
||||||
const hash = window.location.hash.replace('#', '')
|
if (!res?.success) toast(`下载失败:${res?.error ?? '请重试'}`, 'error')
|
||||||
if (!hash) return
|
} catch (_error) {
|
||||||
const target = document.getElementById(hash)
|
toast('下载失败,请重试', 'error')
|
||||||
if (target) {
|
}
|
||||||
void nextTick(() => {
|
return
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// 网页端:新窗口打开直链下载
|
||||||
|
window.open(TUTORIAL_DOWNLOAD_URL, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCatChange(key: 'front' | 'ops' | 'logi') {
|
||||||
|
activeCat.value = key
|
||||||
|
history.replaceState(null, '', `#group-${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHashGroup() {
|
||||||
|
const hash = window.location.hash.replace('#', '')
|
||||||
|
const m = /^group-(front|ops|logi)$/.exec(hash)
|
||||||
|
if (m) activeCat.value = m[1] as 'front' | 'ops' | 'logi'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -204,7 +248,7 @@ onMounted(async () => {
|
|||||||
visibleGroups.value = permissionUnavailable.value
|
visibleGroups.value = permissionUnavailable.value
|
||||||
? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))
|
? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))
|
||||||
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
|
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
|
||||||
scrollToHashGroup()
|
parseHashGroup()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -214,7 +258,11 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #151a25;
|
background-color: #1a1f2e;
|
||||||
|
/* 深色底 + 青色网点纹理(对齐 gemini-code body) */
|
||||||
|
background-image: radial-gradient(rgba(45, 212, 191, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-body {
|
.console-body {
|
||||||
@@ -222,23 +270,46 @@ onMounted(async () => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 22px 34px 44px;
|
padding: 14px 28px 28px;
|
||||||
background: #151a25;
|
background: transparent;
|
||||||
|
/* 滚动条滑块随分类色(保持各分类有独立主色) */
|
||||||
|
scrollbar-color: var(--scroll-color, #2dd4bf) #1a1f2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Hero ===== */
|
.console-body::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body::-webkit-scrollbar-track {
|
||||||
|
background: #1a1f2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body::-webkit-scrollbar-thumb {
|
||||||
|
min-height: 40px;
|
||||||
|
background: var(--scroll-color, #2dd4bf);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body::-webkit-scrollbar-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Hero(仅 front 显示;PANEL 底 + LINE 边框 + 12px 圆角,对齐 gemini-code) ===== */
|
||||||
.hero {
|
.hero {
|
||||||
max-width: 1180px;
|
max-width: 1440px;
|
||||||
margin: 0 auto 26px;
|
margin: 0 auto 20px;
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: linear-gradient(135deg, #1b2233 0%, #151a24 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-inner {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 26px 28px;
|
padding: 26px 30px;
|
||||||
|
border: 1px solid #3e4a62;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #232a3b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-left {
|
.hero-left {
|
||||||
@@ -248,75 +319,78 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.hero-pill {
|
.hero-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 5px 12px;
|
margin-bottom: 12px;
|
||||||
border-radius: 999px;
|
padding: 4px 12px;
|
||||||
background: rgba(45, 212, 191, 0.14);
|
border: 1px solid rgba(45, 212, 191, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #0f2e28;
|
||||||
color: #2dd4bf;
|
color: #2dd4bf;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
margin: 14px 0 0;
|
margin: 0 0 8px;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 28px;
|
font-size: 26px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
max-width: 620px;
|
max-width: 650px;
|
||||||
margin: 10px 0 0;
|
margin: 0 0 20px;
|
||||||
color: #a0acbe;
|
color: #c8d2e2;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 44px;
|
gap: 40px;
|
||||||
margin-top: 22px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat strong {
|
.stat strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat span {
|
.stat span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 3px;
|
margin-top: 4px;
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero 右侧工作台卡 */
|
/* Hero 右侧工作台卡(350 宽 / 10px 圆角,对齐 gemini-code hero-right-card) */
|
||||||
.hero-card {
|
.hero-card {
|
||||||
width: 300px;
|
width: 350px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 18px 20px;
|
display: flex;
|
||||||
border: 1px solid #2e3a52;
|
flex-direction: column;
|
||||||
border-radius: 12px;
|
justify-content: space-between;
|
||||||
background: #202a3e;
|
padding: 20px;
|
||||||
|
border: 1px solid #3e4a62;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #2b3447;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-head {
|
.hero-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-dots {
|
.hero-card-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,70 +400,189 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.dot--blue {
|
.dot--blue {
|
||||||
background: #60a5fa;
|
background: #60a5fa;
|
||||||
margin-left: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot--brand {
|
.dot--brand {
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dots-line {
|
|
||||||
display: block;
|
|
||||||
width: 42px;
|
|
||||||
height: 2px;
|
|
||||||
margin-top: 14px;
|
|
||||||
background: #3e4a62;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-caption {
|
.hero-card-caption {
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-title {
|
.hero-card-title {
|
||||||
margin: 16px 0 0;
|
margin: 0 0 12px;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
line-height: 1.6;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-divider {
|
.hero-card-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 14px 0;
|
margin-bottom: 12px;
|
||||||
background: #3e4a62;
|
background: #3e4a62;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-sub {
|
.hero-card-sub {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #c8d2e2;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下载按钮(渐变文字按钮,对齐 gemini-code download-btn;下载教学客户端 zip 直链) */
|
||||||
|
.hero-card-btn-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card-btn-row .hero-card-sub {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 10px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dl:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 标准工作流时间线(PANEL 底 + LINE 边框 + 圆形编号,对齐 gemini-code) ===== */
|
||||||
|
.workflow-panel {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border: 1px solid #3e4a62;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #232a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-title {
|
||||||
|
color: #f5f8fc;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #3a2a10;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-hint {
|
||||||
|
margin-left: auto;
|
||||||
color: #a0acbe;
|
color: #a0acbe;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.7;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-btn {
|
.workflow-steps {
|
||||||
margin-top: 14px;
|
display: flex;
|
||||||
padding: 9px 22px;
|
align-items: center;
|
||||||
border: 0;
|
justify-content: space-between;
|
||||||
border-radius: 8px;
|
margin: 0;
|
||||||
background: #f59e0b;
|
padding: 0;
|
||||||
color: #fff;
|
list-style: none;
|
||||||
font: inherit;
|
}
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
.workflow-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.18s ease;
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-btn:hover {
|
.workflow-step:not(.workflow-step--disabled):hover {
|
||||||
background: #f7b13c;
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 工具分组 ===== */
|
.workflow-step--disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停:编号圆填充分类色白字、名称变亮(已确认保留的高亮反馈) */
|
||||||
|
.workflow-step:not(.workflow-step--disabled):hover .step-circle {
|
||||||
|
background: var(--gc, #2dd4bf) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step:not(.workflow-step--disabled):hover .step-name {
|
||||||
|
color: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-circle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: #c8d2e2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-desc {
|
||||||
|
color: #a0acbe;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 步骤连接线:圆编号列之间贯穿的横线(垂直居中于编号行,对齐 gemini-code timeline-line) */
|
||||||
|
.step-line {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
margin: 0 4px;
|
||||||
|
transform: translateY(-16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 分类标题 + 卡片墙 ===== */
|
||||||
.tool-group {
|
.tool-group {
|
||||||
max-width: 1180px;
|
max-width: 1440px;
|
||||||
margin: 0 auto 34px;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-head {
|
.group-head {
|
||||||
@@ -407,177 +600,101 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-desc {
|
.group-desc {
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-count {
|
.group-count {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 工作流时间线 ===== */
|
/* 卡片墙(CARD 底 + 2px #2A3344 边框 + 10px 圆角 min-height 165,对齐 gemini-code tool-card) */
|
||||||
.workflow-panel {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
padding: 16px 22px 20px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-title {
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-badge {
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(245, 158, 11, 0.14);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-hint {
|
|
||||||
margin-left: auto;
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-steps {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 16px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step--disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-circle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-name {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-desc {
|
|
||||||
margin-top: 2px;
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 9px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-line {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 12px;
|
|
||||||
height: 2px;
|
|
||||||
margin-top: 15px;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 卡片墙 ===== */
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card {
|
.tool-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 16px 18px 14px;
|
min-height: 165px;
|
||||||
border: 2px solid #2e3a52;
|
padding: 16px;
|
||||||
border-radius: 12px;
|
border: 2px solid #2a3344;
|
||||||
background: #1c2333;
|
border-radius: 10px;
|
||||||
|
background: #2b3447;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.18s ease, background 0.18s ease;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 悬浮:边框高亮为分类色 + 背景变亮 + 上浮阴影(对齐 gemini-code hover 并按要求加边框高亮) */
|
||||||
.tool-card:hover {
|
.tool-card:hover {
|
||||||
border-color: #2dd4bf;
|
background: #323d52;
|
||||||
background: #262f42;
|
border-color: var(--gc, #2dd4bf);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card-top {
|
.tool-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 8px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-tag {
|
.tool-tag {
|
||||||
padding: 3px 9px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 4px;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
font-size: 10px;
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 悬浮:角标变分类色实底白字(保留的亮反馈) */
|
||||||
|
.tool-card:hover .tool-tag {
|
||||||
|
background: var(--gc, #2dd4bf) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-card-name {
|
.tool-card-name {
|
||||||
margin-top: 12px;
|
margin-bottom: 6px;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card-desc {
|
.tool-card-desc {
|
||||||
margin-top: 6px;
|
flex: 1;
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card-go {
|
.tool-card-go {
|
||||||
margin-top: 14px;
|
display: flex;
|
||||||
color: #5e6878;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #a0acbe;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
transition: color 0.18s ease;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card:hover .tool-card-go {
|
.tool-card:hover .tool-card-go {
|
||||||
color: #2dd4bf;
|
color: var(--gc, #2dd4bf);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,31 +704,33 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-card--disabled:hover {
|
.tool-card--disabled:hover {
|
||||||
border-color: #2e3a52;
|
border-color: #2a3344;
|
||||||
background: #1c2333;
|
background: #2b3447;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card--disabled:hover .tool-card-go {
|
.tool-card--disabled:hover .tool-card-go {
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== toast ===== */
|
/* ===== toast(右下角 + 品牌色左边条,对齐 gemini-code toast) ===== */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 60px;
|
||||||
left: 50%;
|
right: 30px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 12px 24px;
|
max-width: min(420px, calc(100vw - 60px));
|
||||||
max-width: min(420px, calc(100vw - 48px));
|
padding: 12px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
border-left: 4px solid #f59e0b;
|
||||||
color: #fff;
|
background: #3d4a63;
|
||||||
font-size: 14px;
|
color: #f5f8fc;
|
||||||
text-align: center;
|
font-size: 13px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateX(-50%);
|
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +739,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast.error {
|
.toast.error {
|
||||||
|
border-left-color: #f87171;
|
||||||
background: rgba(176, 42, 55, 0.92);
|
background: rgba(176, 42, 55, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,8 +748,9 @@ onMounted(async () => {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-inner {
|
.hero {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card {
|
.hero-card {
|
||||||
@@ -637,7 +758,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.console-body {
|
.console-body {
|
||||||
padding: 18px 20px 36px;
|
padding: 14px 16px 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,23 +11,29 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 渐变装饰条固定 青→蓝→紫→橙(对齐 gemini-code footer-gradient-bar)
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.amazon-footer {
|
.amazon-footer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: #151a25;
|
background: #1a1f2e;
|
||||||
|
border-top: 1px solid #3e4a62;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amazon-footer__gradient {
|
.amazon-footer__gradient {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: linear-gradient(90deg, #2dd4bf 0%, #60a5fa 55%, #a78bfa 72%, #f59e0b 87%, #f59e0b 100%);
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #2dd4bf 0%, #60a5fa 60%, #a78bfa 85%, #f59e0b 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.amazon-footer__inner {
|
.amazon-footer__inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
padding: 8px 12px;
|
padding: 10px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bug-banner {
|
.bug-banner {
|
||||||
@@ -35,24 +41,26 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 48px;
|
gap: 40px;
|
||||||
padding: 9px 24px;
|
padding: 10px 24px;
|
||||||
border: 1px solid #2dd4bf;
|
border: 1px solid #2dd4bf;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.18s ease, border-color 0.18s ease;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bug-banner:hover {
|
.bug-banner:hover {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
border-color: #60a5fa;
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 12px rgba(45, 212, 191, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bug-banner__title {
|
.bug-banner__title {
|
||||||
|
flex-shrink: 0;
|
||||||
color: #2dd4bf;
|
color: #2dd4bf;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
<AmazonTopBar :active-cat="groupKey" />
|
<AmazonTopBar :active-cat="groupKey" />
|
||||||
|
|
||||||
<div class="tool-head">
|
<div class="tool-head">
|
||||||
<a class="tool-head__back" :href="backHref">← 返回{{ group ? group.name : '工具' }}列表</a>
|
<router-link class="tool-head__back" :to="backHref">← 返回{{ group ? group.name : '工具' }}列表</router-link>
|
||||||
<span class="tool-head__sep"></span>
|
|
||||||
<span class="tool-head__title">{{ tool?.name ?? '' }}</span>
|
<span class="tool-head__title">{{ tool?.name ?? '' }}</span>
|
||||||
<span class="tool-head__desc">{{ detailText }}</span>
|
<span class="tool-head__desc">{{ detailText }}</span>
|
||||||
|
<a
|
||||||
|
v-if="tool?.id === 'source'"
|
||||||
|
class="tool-head__aliprice"
|
||||||
|
:href="ALIPRICE_URL"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Aliprice点击注册</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具说明提示卡已按需求移除(原棕色底 note 卡区域) -->
|
||||||
|
|
||||||
<div class="tool-shell-body">
|
<div class="tool-shell-body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
@@ -25,6 +33,10 @@ import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
|
|||||||
import { TOOL_DETAILS, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
|
import { TOOL_DETAILS, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
|
||||||
import { resolvePageHref } from '@/shared/page-prefix'
|
import { resolvePageHref } from '@/shared/page-prefix'
|
||||||
|
|
||||||
|
/** Aliprice 注册推广链接(对齐 gemini-code 工具页头 Aliprice 按钮) */
|
||||||
|
const ALIPRICE_URL =
|
||||||
|
'https://www.aiprice.com/?ext_id=10100&channel=chrome_offline&platform=1688&version=4.0.5&browser=chrome&mv=3'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 工具 id(见 tool-catalog TOOL_MAP) */
|
/** 工具 id(见 tool-catalog TOOL_MAP) */
|
||||||
toolId: string
|
toolId: string
|
||||||
@@ -47,53 +59,79 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
background-color: #1a1f2e;
|
||||||
|
/* 深色底 + 青色网点纹理(对齐 gemini-code body) */
|
||||||
|
background-image: radial-gradient(rgba(45, 212, 191, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-head {
|
.tool-head {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
padding: 10px 34px;
|
padding: 10px 34px;
|
||||||
background: #151a25;
|
background: #1a1f2e;
|
||||||
border-bottom: 1px solid #2a3344;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-head__back {
|
.tool-head__back {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #3e4a62;
|
||||||
|
border-radius: 6px;
|
||||||
color: #c8d2e2;
|
color: #c8d2e2;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-head__back:hover {
|
.tool-head__back:hover {
|
||||||
color: #fff;
|
background: #2b3447;
|
||||||
}
|
color: #f5f8fc;
|
||||||
|
|
||||||
.tool-head__sep {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1px;
|
|
||||||
height: 18px;
|
|
||||||
background: #3e4a62;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-head__title {
|
.tool-head__title {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-head__desc {
|
.tool-head__desc {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
max-width: 650px;
|
||||||
color: #a0acbe;
|
color: #a0acbe;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aliprice 注册按钮(仅货源查询显示,TEAL 渐变,对齐 gemini-code aliprice-btn) */
|
||||||
|
.tool-head__aliprice {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: linear-gradient(135deg, #14b8a6, #0d9488);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-head__aliprice:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-shell-body {
|
.tool-shell-body {
|
||||||
@@ -105,8 +143,7 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
|
|||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.tool-head {
|
.tool-head {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
flex-wrap: wrap;
|
gap: 10px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="amazon-top">
|
<header class="amazon-top">
|
||||||
<div class="brand">
|
<router-link class="brand" :to="toolbarHref" title="返回工具台首页">
|
||||||
<span class="brand-logo">富</span>
|
<span class="brand-logo">富</span>
|
||||||
<div class="brand-text">
|
<span class="brand-text">
|
||||||
<span class="brand-name">数富AI</span>
|
<span class="brand-name">数富AI</span>
|
||||||
<span class="brand-sub">亚马逊运营工具台</span>
|
<span class="brand-sub">亚马逊运营工具台</span>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</router-link>
|
||||||
|
|
||||||
<nav class="cat-tabs" aria-label="分类导航">
|
<nav class="cat-tabs" aria-label="分类导航">
|
||||||
<template v-for="cat in visibleCats" :key="cat.key">
|
<template v-for="cat in visibleCats" :key="cat.key">
|
||||||
<a
|
<router-link
|
||||||
class="cat-tab"
|
class="cat-tab"
|
||||||
:class="{ 'cat-tab--active': cat.key === activeCat }"
|
:class="{ 'cat-tab--active': cat.key === activeCat }"
|
||||||
:href="`${toolbarHref}#group-${cat.key}`"
|
:to="`${toolbarHref}#group-${cat.key}`"
|
||||||
|
@click="onTabClick(cat.key)"
|
||||||
>
|
>
|
||||||
<span class="cat-dot" :style="{ background: cat.color }"></span>
|
<span class="cat-tab-content">
|
||||||
<span class="cat-name">{{ cat.name }}</span>
|
<span class="cat-dot" :style="{ background: cat.color }"></span>
|
||||||
<span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span>
|
<span class="cat-name">{{ cat.name }}</span>
|
||||||
<span class="cat-underline" :style="{ background: cat.color }"></span>
|
<span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span>
|
||||||
</a>
|
</span>
|
||||||
|
<span class="cat-underline" :style="underlineStyle(cat)"></span>
|
||||||
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<a href="/home" class="btn-home">返回首页</a>
|
<BrandApiSecretSettingsButton variant="topbar" />
|
||||||
<BrandApiSecretSettingsButton />
|
<router-link class="help-btn" :to="workHref" title="采集工作台">⚙</router-link>
|
||||||
<span class="help-mark" title="帮助">?</span>
|
<span class="help-btn" title="如需支持请点击底部BUG登记系统">?</span>
|
||||||
|
<span class="admin-chip"><span class="admin-badge">AD</span>{{ username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,17 +54,34 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ change: [key: 'front' | 'ops' | 'logi'] }>()
|
||||||
|
|
||||||
const activeCat = computed(() => props.activeCat)
|
const activeCat = computed(() => props.activeCat)
|
||||||
const visibleCats = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
const visibleCats = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
||||||
|
const username = ref('admin')
|
||||||
|
|
||||||
/** 分类 tab 点击回到工具台对应分组 */
|
/** 分类 tab 点击回到工具台对应分组 */
|
||||||
const toolbarHref = resolvePageHref('/new_web_source/amazon-console.html')
|
const toolbarHref = resolvePageHref('/new_web_source/amazon-console.html')
|
||||||
|
/** ⚙ 采集工作台:直达现有真实功能页(对齐 gemini-code 顶栏 ⚙ 入口) */
|
||||||
|
const workHref = resolvePageHref('/new_web_source/collect-data.html')
|
||||||
|
|
||||||
|
function onTabClick(key: 'front' | 'ops' | 'logi') {
|
||||||
|
emit('change', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 激活分类角标:分类色实底白字(对齐 gemini-code tab-badge 激活态) */
|
||||||
function badgeStyle(cat: VisibleGroup) {
|
function badgeStyle(cat: VisibleGroup) {
|
||||||
|
return activeCat.value === cat.key ? { background: cat.color, color: '#FFFFFF' } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 激活分类下划线(对齐 gemini-code tab-underline:底部 3px 分类色圆角条) */
|
||||||
|
function underlineStyle(cat: VisibleGroup) {
|
||||||
return activeCat.value === cat.key ? { background: cat.color } : {}
|
return activeCat.value === cat.key ? { background: cat.color } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const localName = typeof window === 'undefined' ? '' : (window.localStorage.getItem('username') || '').trim()
|
||||||
|
if (localName) username.value = localName
|
||||||
try {
|
try {
|
||||||
const keys = await getCurrentUserAppColumnKeys()
|
const keys = await getCurrentUserAppColumnKeys()
|
||||||
visibleCats.value = filterGroupsByPermission(TOOL_GROUPS, keys)
|
visibleCats.value = filterGroupsByPermission(TOOL_GROUPS, keys)
|
||||||
@@ -79,9 +100,10 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 0 24px;
|
padding: 0 28px;
|
||||||
background: #151a25;
|
background: #1a1f2e;
|
||||||
border-bottom: 1px solid #2a3344;
|
border-bottom: 1px solid #3e4a62;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -89,6 +111,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
@@ -97,11 +120,12 @@ onMounted(async () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 9px;
|
border-radius: 8px;
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 15px;
|
font-size: 20px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
@@ -112,14 +136,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
.brand-sub {
|
||||||
color: #5e6878;
|
color: #a0acbe;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -130,36 +153,46 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-tab {
|
.cat-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
width: 140px;
|
||||||
min-width: 132px;
|
height: 42px;
|
||||||
height: 38px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #c8d2e2;
|
color: #c8d2e2;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.18s ease, color 0.18s ease;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-tab:hover {
|
.cat-tab-content {
|
||||||
background: #232a3b;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-tab:hover .cat-tab-content {
|
||||||
|
background: #3d4a63;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-tab--active {
|
.cat-tab--active {
|
||||||
background: #3d4a63;
|
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cat-tab--active .cat-tab-content {
|
||||||
|
background: #3d4a63;
|
||||||
|
}
|
||||||
|
|
||||||
.cat-dot {
|
.cat-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -171,33 +204,23 @@ onMounted(async () => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 22px;
|
padding: 1px 7px;
|
||||||
height: 18px;
|
border-radius: 12px;
|
||||||
padding: 0 4px;
|
background: #343f55;
|
||||||
border-radius: 999px;
|
color: #a0acbe;
|
||||||
background: #2b3447;
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
}
|
|
||||||
|
|
||||||
.cat-tab--active .cat-badge {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-underline {
|
.cat-underline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 0;
|
||||||
right: 10px;
|
right: 0;
|
||||||
bottom: -4px;
|
bottom: 0;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
border-radius: 2px;
|
border-radius: 3px 3px 0 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
opacity: 0;
|
transition: background 0.2s;
|
||||||
}
|
|
||||||
|
|
||||||
.cat-tab--active .cat-underline {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-right {
|
.top-right {
|
||||||
@@ -207,29 +230,45 @@ onMounted(async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-home {
|
.help-btn {
|
||||||
color: #a0acbe;
|
|
||||||
font-size: 13px;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-home:hover {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-mark {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
min-width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
color: #c8d2e2;
|
||||||
background: #232a3b;
|
font-size: 16px;
|
||||||
color: #a0acbe;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
text-decoration: none;
|
||||||
font-weight: 800;
|
cursor: pointer;
|
||||||
cursor: help;
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-btn:hover {
|
||||||
|
color: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #c8d2e2;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: #2a6b4f;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
name: string
|
name: string
|
||||||
size?: number
|
size?: number
|
||||||
|
/** 分类主色:传入时图标用该纯色底(对齐主程序 _get_icon 按分类色) */
|
||||||
|
color?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 48,
|
size: 48,
|
||||||
|
color: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const gradient = computed(() => TOOL_ICON_GRADIENTS[props.name] ?? TOOL_ICON_GRADIENTS.collect)
|
const gradient = computed(() => props.color ?? TOOL_ICON_GRADIENTS[props.name] ?? TOOL_ICON_GRADIENTS.collect)
|
||||||
const shape = computed(() => TOOL_ICON_SHAPES[props.name] ?? TOOL_ICON_SHAPES.collect)
|
const shape = computed(() => TOOL_ICON_SHAPES[props.name] ?? TOOL_ICON_SHAPES.collect)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 工具台首页「立即下载教程」:数富AI 教学客户端压缩包公开直链。
|
||||||
|
*
|
||||||
|
* 对象位于 MinIO 公开 client 桶(host A minio 容器,openresty 反代 oss.aishufu.top),
|
||||||
|
* 由 scripts/upload_tutorial_zip.py 上传(同 key 可覆盖更新)。
|
||||||
|
*/
|
||||||
|
export const TUTORIAL_DOWNLOAD_URL = 'https://oss.aishufu.top/client/tutorial/数富AI-教学客户端.zip'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 亚马逊运营工具台 · 工具目录
|
* 亚马逊运营工具台 · 工具目录
|
||||||
*
|
*
|
||||||
* 来源:reference/数富AI工具台_含源代码/数富AI工具台_主程序.py 的 CATS 数据,
|
* 来源:原 tkinter 版工具台(reference/数富AI工具台_含源代码,已废弃删除)的 CATS 数据,
|
||||||
* 页面链接按现有 BrandTopBar 导航映射到既有功能页(表单 + 右侧任务全部保留现有)。
|
* 页面链接按现有 BrandTopBar 导航映射到既有功能页(表单 + 右侧任务全部保留现有)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -47,9 +47,6 @@ export type ToolGroup = {
|
|||||||
workflow?: WorkflowStep[]
|
workflow?: WorkflowStep[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 现有功能页地址(全部为 new_web_source 的 Vue 构建页) */
|
|
||||||
const VM = '/new_web_source'
|
|
||||||
|
|
||||||
export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||||
{
|
{
|
||||||
key: 'front',
|
key: 'front',
|
||||||
@@ -77,7 +74,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
name: '采集数据',
|
name: '采集数据',
|
||||||
desc: '批量采集站点商品数据,按照自己要求数据',
|
desc: '批量采集站点商品数据,按照自己要求数据',
|
||||||
tag: '流程·1',
|
tag: '流程·1',
|
||||||
href: `${VM}/collect-data.html`,
|
href: '/collect-data',
|
||||||
permissionKeys: ['collect-data'],
|
permissionKeys: ['collect-data'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,7 +82,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
name: 'ASIN变体采集',
|
name: 'ASIN变体采集',
|
||||||
desc: '支持粘贴和文档,按照采集国家选择导出',
|
desc: '支持粘贴和文档,按照采集国家选择导出',
|
||||||
tag: '流程·2',
|
tag: '流程·2',
|
||||||
href: `${VM}/variant-collection.html`,
|
href: '/variant-collection',
|
||||||
permissionKeys: ['variant-collection'],
|
permissionKeys: ['variant-collection'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,7 +91,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
desc: '全站唯一 ASIN 归属:谁先采集归谁,自动过滤撞款',
|
desc: '全站唯一 ASIN 归属:谁先采集归谁,自动过滤撞款',
|
||||||
tag: '重点',
|
tag: '重点',
|
||||||
core: true,
|
core: true,
|
||||||
href: `${VM}/dedupe.html`,
|
href: '/dedupe',
|
||||||
permissionKeys: ['dedupe'],
|
permissionKeys: ['dedupe'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,7 +99,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
name: '货源查询',
|
name: '货源查询',
|
||||||
desc: '关闭图片检查和类目检测不扣费,打开需要算力费用',
|
desc: '关闭图片检查和类目检测不扣费,打开需要算力费用',
|
||||||
tag: '流程·4',
|
tag: '流程·4',
|
||||||
href: `${VM}/similar-asin.html`,
|
href: '/similar-asin',
|
||||||
permissionKeys: ['similar-asin'],
|
permissionKeys: ['similar-asin'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +107,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
name: '品牌检测',
|
name: '品牌检测',
|
||||||
desc: '核查上架数据品牌风险,精确 / 嵌入两种策略',
|
desc: '核查上架数据品牌风险,精确 / 嵌入两种策略',
|
||||||
tag: '流程·5',
|
tag: '流程·5',
|
||||||
href: `${VM}/brand.html`,
|
href: '/brand',
|
||||||
permissionKeys: ['brand'],
|
permissionKeys: ['brand'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,21 +115,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
name: '外观专利检测',
|
name: '外观专利检测',
|
||||||
desc: '检测商品外观专利,版权,标题,图片。详细页风险,规避下架',
|
desc: '检测商品外观专利,版权,标题,图片。详细页风险,规避下架',
|
||||||
tag: '流程·6',
|
tag: '流程·6',
|
||||||
href: `${VM}/appearance-patent.html`,
|
href: '/appearance-patent',
|
||||||
permissionKeys: ['appearance-patent'],
|
permissionKeys: ['appearance-patent'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'split',
|
id: 'split',
|
||||||
name: '数据拆分',
|
name: '数据拆分',
|
||||||
desc: '大文件按规则拆分,便于分批处理与上传',
|
desc: '大文件按规则拆分,便于分批处理与上传',
|
||||||
href: `${VM}/split.html`,
|
href: '/split',
|
||||||
permissionKeys: ['split'],
|
permissionKeys: ['split'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'convert',
|
id: 'convert',
|
||||||
name: '格式转换',
|
name: '格式转换',
|
||||||
desc: '数据格式一键转换,适配各工具导入',
|
desc: '数据格式一键转换,适配各工具导入',
|
||||||
href: `${VM}/convert.html`,
|
href: '/convert',
|
||||||
permissionKeys: ['convert'],
|
permissionKeys: ['convert'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -164,14 +161,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
id: 'list',
|
id: 'list',
|
||||||
name: '上架',
|
name: '上架',
|
||||||
desc: '把安全数据批量上架到指定店铺',
|
desc: '把安全数据批量上架到指定店铺',
|
||||||
href: `${VM}/publish.html`,
|
href: '/publish',
|
||||||
permissionKeys: ['publish'],
|
permissionKeys: ['publish'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delasin',
|
id: 'delasin',
|
||||||
name: '指定删除ASIN',
|
name: '指定删除ASIN',
|
||||||
desc: '批量删除需清理的 ASIN(风险 / 优化 / 邮箱汇总)',
|
desc: '批量删除需清理的 ASIN(风险 / 优化 / 邮箱汇总)',
|
||||||
href: `${VM}/delete-brand.html`,
|
href: '/delete-brand',
|
||||||
permissionKeys: ['delete-brand'],
|
permissionKeys: ['delete-brand'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,14 +177,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
desc: '4 类风险一键处理:禁止显示 / 需要批准 / 详情已删 / 账户状态',
|
desc: '4 类风险一键处理:禁止显示 / 需要批准 / 详情已删 / 账户状态',
|
||||||
tag: '高频',
|
tag: '高频',
|
||||||
core: true,
|
core: true,
|
||||||
href: `${VM}/product-risk.html`,
|
href: '/product-risk',
|
||||||
permissionKeys: ['product-risk'],
|
permissionKeys: ['product-risk'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'time',
|
id: 'time',
|
||||||
name: '定时匹配',
|
name: '定时匹配',
|
||||||
desc: '定时价格匹配,更快更及时抢回购物车',
|
desc: '定时价格匹配,更快更及时抢回购物车',
|
||||||
href: `${VM}/shop-match.html`,
|
href: '/shop-match',
|
||||||
permissionKeys: ['shop-match'],
|
permissionKeys: ['shop-match'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -196,21 +193,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
desc: '核心工具 · 时时跟价抢购物车,支持指定 ASIN 快速跟价',
|
desc: '核心工具 · 时时跟价抢购物车,支持指定 ASIN 快速跟价',
|
||||||
tag: '核心',
|
tag: '核心',
|
||||||
core: true,
|
core: true,
|
||||||
href: `${VM}/price-track.html`,
|
href: '/price-track',
|
||||||
permissionKeys: ['pricing', 'price-track'],
|
permissionKeys: ['pricing', 'price-track'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'patrol',
|
id: 'patrol',
|
||||||
name: '巡店删除',
|
name: '巡店删除',
|
||||||
desc: '按商品状态巡店清理,摸清店铺商品全貌',
|
desc: '按商品状态巡店清理,摸清店铺商品全貌',
|
||||||
href: `${VM}/patrol-delete.html`,
|
href: '/patrol-delete',
|
||||||
permissionKeys: ['patrol-delete'],
|
permissionKeys: ['patrol-delete'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qasin',
|
id: 'qasin',
|
||||||
name: '查询ASIN',
|
name: '查询ASIN',
|
||||||
desc: '排查出单 ASIN 是否仍在售,保住优质数据',
|
desc: '排查出单 ASIN 是否仍在售,保住优质数据',
|
||||||
href: `${VM}/query-asin.html`,
|
href: '/query-asin',
|
||||||
permissionKeys: ['query-asin'],
|
permissionKeys: ['query-asin'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -219,14 +216,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
|||||||
desc: '核心 · 抓取店铺排名 / 品牌 / 销售,支撑复查与优化',
|
desc: '核心 · 抓取店铺排名 / 品牌 / 销售,支撑复查与优化',
|
||||||
tag: '核心',
|
tag: '核心',
|
||||||
core: true,
|
core: true,
|
||||||
href: `${VM}/shop-data-crawl.html`,
|
href: '/shop-data-crawl',
|
||||||
permissionKeys: ['shop-data-crawl', 'shop_data_crawl'],
|
permissionKeys: ['shop-data-crawl', 'shop_data_crawl'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cash',
|
id: 'cash',
|
||||||
name: '取款',
|
name: '取款',
|
||||||
desc: '留足平台扣款费用,其余全部提现',
|
desc: '留足平台扣款费用,其余全部提现',
|
||||||
href: `${VM}/withdraw.html`,
|
href: '/withdraw',
|
||||||
permissionKeys: ['withdraw'],
|
permissionKeys: ['withdraw'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -345,7 +342,7 @@ export const TOOL_DETAILS: Readonly<Record<string, string>> = {
|
|||||||
brand: '核查上架数据品牌风险,先跑精确匹配,后跑嵌入式',
|
brand: '核查上架数据品牌风险,先跑精确匹配,后跑嵌入式',
|
||||||
delasin: '收集统一要删除的ASIN,批量删除清理',
|
delasin: '收集统一要删除的ASIN,批量删除清理',
|
||||||
risk:
|
risk:
|
||||||
'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需审批时用后台处理)。',
|
'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需要审批没有时候后台处理)。',
|
||||||
follow:
|
follow:
|
||||||
'一个虚拟桌面跟一个国家,错开跑即可时时跟价抢购物车。指定 ASIN 文档跟价:用后台有利润数据快速跟价,下班前跑一下,确保出单数据及时是我们的购物车。',
|
'一个虚拟桌面跟一个国家,错开跑即可时时跟价抢购物车。指定 ASIN 文档跟价:用后台有利润数据快速跟价,下班前跑一下,确保出单数据及时是我们的购物车。',
|
||||||
patrol: '巡店删除是按商品状态删除,如审核多次不通过的商品。不放任何状态跑一遍,可清楚掌握店铺数据情况。',
|
patrol: '巡店删除是按商品状态删除,如审核多次不通过的商品。不放任何状态跑一遍,可清楚掌握店铺数据情况。',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="secret-settings-entry">
|
<div class="secret-settings-entry">
|
||||||
<button type="button" class="secret-settings-trigger" @click="dialogVisible = true">
|
<button type="button" class="secret-settings-trigger" :class="{ 'variant-topbar': variant === 'topbar' }" @click="dialogVisible = true">
|
||||||
<span class="secret-settings-icon" aria-hidden="true">◎</span>
|
<span class="secret-settings-icon" aria-hidden="true">◎</span>
|
||||||
<span>密钥设置</span>
|
<span>密钥设置</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -173,6 +173,16 @@ type SecretState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
|
||||||
|
variant?: 'topbar'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
const proxyUrl = ref('')
|
const proxyUrl = ref('')
|
||||||
const proxyMode = ref<ProxyMode>(1)
|
const proxyMode = ref<ProxyMode>(1)
|
||||||
const proxyLoading = ref(false)
|
const proxyLoading = ref(false)
|
||||||
@@ -378,6 +388,29 @@ loadStates()
|
|||||||
border-color: #4c647d;
|
border-color: #4c647d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* topbar 变体:纯文字「🔑 密钥设置」,hover 整块变 CARD2(对齐主程序顶栏) */
|
||||||
|
.secret-settings-trigger.variant-topbar {
|
||||||
|
height: auto;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #c8d2e2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-settings-trigger.variant-topbar:hover {
|
||||||
|
background: #343f55;
|
||||||
|
color: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-settings-trigger.variant-topbar .secret-settings-icon {
|
||||||
|
color: #c8d2e2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.secret-settings-icon {
|
.secret-settings-icon {
|
||||||
color: #8dc4ff;
|
color: #8dc4ff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
|
||||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中...' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 LLM,当前任务区展示 LLM 回流和结果组装进度。</p>
|
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 LLM,当前任务区展示 LLM 回流和结果组装进度。</p>
|
||||||
@@ -41,96 +41,32 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">外观专利检测</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="外观专利检测"
|
||||||
<div class="summary-card">
|
:cards="patentCards"
|
||||||
<span class="summary-label">运行中任务</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.pendingTaskCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无当前任务"
|
||||||
<span class="summary-label">已结束任务</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
>
|
||||||
</div>
|
<template #item-extra="{ item }">
|
||||||
<div class="summary-card">
|
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||||
<span class="summary-label">成功任务</span>
|
</template>
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
<template #history-item-extra="{ item }">
|
||||||
</div>
|
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||||
<div class="summary-card">
|
</template>
|
||||||
<span class="summary-label">失败任务</span>
|
<template #item-actions="{ item }">
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
<template #history-item-actions="{ item }">
|
||||||
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div class="subsection-title">匹配任务</div>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
<div class="result-list-wrap">
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
<div class="result-list-header">
|
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<span>当前任务</span>
|
</template>
|
||||||
</div>
|
</TaskCenterPanel>
|
||||||
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
|
||||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
|
||||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
|
||||||
<div v-if="showFileProgress(item)" class="file-progress">
|
|
||||||
<div class="file-progress-meta">
|
|
||||||
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
|
|
||||||
<span>{{ fileProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-progress-track">
|
|
||||||
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status running">{{ statusText(item) }}</span>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>历史记录</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
|
||||||
<div v-if="item.resultFilename" class="files">
|
|
||||||
{{ item.resultFilename || '下载结果' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
|
||||||
<div v-if="showFileProgress(item)" class="file-progress">
|
|
||||||
<div class="file-progress-meta">
|
|
||||||
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
|
|
||||||
<span>{{ fileProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-progress-track">
|
|
||||||
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -142,6 +78,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import {
|
import {
|
||||||
activateAppearancePatentTask,
|
activateAppearancePatentTask,
|
||||||
deleteAppearancePatentHistory,
|
deleteAppearancePatentHistory,
|
||||||
@@ -244,6 +182,51 @@ const historyOnlyItems = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const patentCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
currentItems.value.map(toPatentTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
historyOnlyItems.value.map(toPatentTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as AppearancePatentHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPatentTaskView(item: AppearancePatentHistoryItem): TaskItemView {
|
||||||
|
const showProgress = showFileProgress(item)
|
||||||
|
const progressPercent = fileProgressPercent(item)
|
||||||
|
return {
|
||||||
|
key: `patent-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '外观专利检测',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
|
||||||
|
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
|
||||||
|
...(item.error ? [`错误:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
progress: showProgress
|
||||||
|
? {
|
||||||
|
percent: progressPercent,
|
||||||
|
stage: displayFileProgressMessage(item, '处理中'),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
|
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
|
||||||
if (item.taskId == null) return item
|
if (item.taskId == null) return item
|
||||||
const liveItem = liveProgressItems.value[item.taskId]
|
const liveItem = liveProgressItems.value[item.taskId]
|
||||||
@@ -531,7 +514,7 @@ async function pushToPythonQueue() {
|
|||||||
clearParsedTask()
|
clearParsedTask()
|
||||||
await loadDashboard()
|
await loadDashboard()
|
||||||
await loadHistory({ force: true })
|
await loadHistory({ force: true })
|
||||||
ElMessage.success('已推送到 Python 队列')
|
ElMessage.success('已启动任务')
|
||||||
} finally {
|
} finally {
|
||||||
pushing.value = false
|
pushing.value = false
|
||||||
}
|
}
|
||||||
@@ -979,6 +962,26 @@ onUnmounted(() => {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1006,36 +1009,12 @@ onUnmounted(() => {
|
|||||||
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
|
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
|
||||||
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2e3a52; border-radius: 8px; background: #222b3d; color: #a0acbe; font-size: 12px; }
|
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2e3a52; border-radius: 8px; background: #222b3d; color: #a0acbe; font-size: 12px; }
|
||||||
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
|
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
|
||||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
|
|
||||||
.summary-label { color: #5e6878; font-size: 12px; }
|
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
|
|
||||||
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
|
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
|
|
||||||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.task-list { list-style: none; margin: 0; padding: 12px; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
|
|
||||||
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
|
|
||||||
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
|
|
||||||
.result-hint { margin-top: 6px; color: #e0b96d; }
|
.result-hint { margin-top: 6px; color: #e0b96d; }
|
||||||
.file-progress { margin-top: 8px; max-width: 520px; }
|
|
||||||
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
|
|
||||||
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #333f55; border: 1px solid #3b3b3b; }
|
|
||||||
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
|
|
||||||
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
|
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
|
||||||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,44 +76,36 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-actions-row">
|
||||||
<span>品牌检测任务</span>
|
|
||||||
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
|
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list-wrap">
|
<TaskCenterPanel
|
||||||
<div v-if="!tasks.length" class="empty-tasks">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
暂无品牌检测任务。选择待检文件后点击「运行」或「添加到任务队列」。
|
title="品牌检测任务"
|
||||||
</div>
|
:cards="brandCards"
|
||||||
<ul v-else class="task-list">
|
:current-items="currentTaskViews"
|
||||||
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item">
|
:history-items="historyTaskViews"
|
||||||
<div class="left">
|
current-title="当前任务"
|
||||||
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span>
|
current-empty-text="暂无当前任务,提交检测后运行中任务会显示在这里"
|
||||||
<div class="files">任务 ID:{{ item.id }}</div>
|
history-empty-text="暂无历史记录"
|
||||||
<div v-if="item.file_paths?.length" class="files">文件:{{ item.file_paths.length }} 个</div>
|
>
|
||||||
<div class="files">创建时间:{{ formatDateTime(item.created_at) }}</div>
|
<template #item-actions="{ item }">
|
||||||
<template v-if="isRunning(item)">
|
<template v-if="itemSource(item)">
|
||||||
<div class="task-progress">
|
<button v-if="isRunning(itemSource(item))" type="button" class="btn-delete"
|
||||||
<div class="task-progress-meta">
|
@click="cancelTask(itemSource(item))">取消</button>
|
||||||
<span>任务进度</span>
|
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||||||
<span>{{ taskProgress(item) }}%</span>
|
@click="deleteTask(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
<div class="task-progress-track">
|
</template>
|
||||||
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div>
|
<template #history-item-actions="{ item }">
|
||||||
</div>
|
<template v-if="itemSource(item)">
|
||||||
</div>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div>
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
</template>
|
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||||||
<div v-else-if="errorMessage(item)" class="files error">错误:{{ errorMessage(item) }}</div>
|
@click="deleteTask(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
<div class="task-right">
|
</template>
|
||||||
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
|
</TaskCenterPanel>
|
||||||
<button v-if="isRunning(item)" type="button" class="act-btn danger" @click="cancelTask(item)">取消</button>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="act-btn ok" @click="downloadResult(item)">下载结果</button>
|
|
||||||
<button v-if="canDelete(item)" type="button" class="act-btn" @click="deleteTask(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -124,18 +116,21 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import { requestPostJson } from '@/shared/api/http'
|
|
||||||
import {
|
import {
|
||||||
cancelBrandTask,
|
cancelBrandTask,
|
||||||
createBrandTask,
|
createBrandTask,
|
||||||
deleteBrandTask,
|
deleteBrandTask,
|
||||||
|
expandBrandFolder,
|
||||||
getBrandTaskDownloadUrl,
|
getBrandTaskDownloadUrl,
|
||||||
getBrandTasks,
|
getBrandTasks,
|
||||||
runBrandNow,
|
runBrandNow,
|
||||||
type BrandTaskItem,
|
type BrandTaskItem,
|
||||||
} from '@/shared/api/brand'
|
} from '@/shared/api/brand'
|
||||||
|
|
||||||
|
/** 扩展文件夹接口响应(向后端真实字段 paths 兼容,shared 类型仅声明 items 时以本接口为准) */
|
||||||
interface BrandExpandFolderPathsResponse {
|
interface BrandExpandFolderPathsResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
paths?: string[]
|
paths?: string[]
|
||||||
@@ -172,6 +167,11 @@ function isRunning(item: BrandTaskItem) {
|
|||||||
return (item.status || '').toLowerCase() === 'running'
|
return (item.status || '').toLowerCase() === 'running'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusyBrandTask(item: BrandTaskItem) {
|
||||||
|
const status = (item.status || '').toLowerCase()
|
||||||
|
return status === 'pending' || status === 'running'
|
||||||
|
}
|
||||||
|
|
||||||
function isTerminal(item: BrandTaskItem) {
|
function isTerminal(item: BrandTaskItem) {
|
||||||
const status = (item.status || '').toLowerCase()
|
const status = (item.status || '').toLowerCase()
|
||||||
return status === 'success' || status === 'failed' || status === 'cancelled'
|
return status === 'success' || status === 'failed' || status === 'cancelled'
|
||||||
@@ -193,11 +193,6 @@ function taskProgress(item: BrandTaskItem) {
|
|||||||
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
|
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskDesc(item: BrandTaskItem) {
|
|
||||||
const desc = (item.desc || '').trim()
|
|
||||||
return desc ? (desc.length > 60 ? `${desc.slice(0, 60)}…` : desc) : `任务 #${item.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(item: BrandTaskItem) {
|
function errorMessage(item: BrandTaskItem) {
|
||||||
return (item.error_message || '').trim()
|
return (item.error_message || '').trim()
|
||||||
}
|
}
|
||||||
@@ -205,7 +200,7 @@ function errorMessage(item: BrandTaskItem) {
|
|||||||
function statusText(status?: BrandTaskItem['status']) {
|
function statusText(status?: BrandTaskItem['status']) {
|
||||||
const value = (status || '').toLowerCase()
|
const value = (status || '').toLowerCase()
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending: '等待中',
|
pending: '排队中',
|
||||||
running: '执行中',
|
running: '执行中',
|
||||||
success: '已完成',
|
success: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
@@ -219,10 +214,57 @@ function statusClass(status?: BrandTaskItem['status']) {
|
|||||||
if (value === 'running') return 'running'
|
if (value === 'running') return 'running'
|
||||||
if (value === 'success') return 'success'
|
if (value === 'success') return 'success'
|
||||||
if (value === 'failed') return 'failed'
|
if (value === 'failed') return 'failed'
|
||||||
if (value === 'cancelled') return 'cancelled'
|
|
||||||
return 'pending'
|
return 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||||
|
/** 当前任务:排队中 / 执行中的任务 */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
tasks.value.filter(isBusyBrandTask).map(toBrandTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 历史任务:终态(成功 / 失败 / 已取消 / 其他状态)的任务 */
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
tasks.value.filter((item) => !isBusyBrandTask(item)).map(toBrandTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
const brandCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||||||
|
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||||||
|
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): BrandTaskItem {
|
||||||
|
return item.source as BrandTaskItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
|
||||||
|
const total = Number(item.progress_total) || 0
|
||||||
|
const current = Number(item.progress_current) || 0
|
||||||
|
return {
|
||||||
|
key: `brand-${item.id}`,
|
||||||
|
title: (item.desc || '').trim() || '品牌检测',
|
||||||
|
taskId: item.id,
|
||||||
|
startedAt: formatDateTime(item.created_at),
|
||||||
|
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
|
||||||
|
statusText: statusText(item.status),
|
||||||
|
statusClass: statusClass(item.status),
|
||||||
|
extraLines: [
|
||||||
|
...(item.file_paths?.length ? [`文件:${item.file_paths.length} 个`] : []),
|
||||||
|
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
|
||||||
|
],
|
||||||
|
progress: isRunning(item)
|
||||||
|
? {
|
||||||
|
percent: taskProgress(item),
|
||||||
|
stage: '任务进度',
|
||||||
|
countLabel: total > 0 ? `${current}/${total}` : undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string) {
|
function formatDateTime(value?: string) {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -267,7 +309,7 @@ async function selectFolder() {
|
|||||||
try {
|
try {
|
||||||
const folder = await bridge.select_brand_folder()
|
const folder = await bridge.select_brand_folder()
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
const res = await requestPostJson<BrandExpandFolderPathsResponse>('/api/brand/expand-folder', { folder })
|
const res = (await expandBrandFolder(folder)) as BrandExpandFolderPathsResponse
|
||||||
if (!res.success || !res.paths?.length) {
|
if (!res.success || !res.paths?.length) {
|
||||||
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
|
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
|
||||||
return
|
return
|
||||||
@@ -433,6 +475,26 @@ onBeforeUnmount(() => {
|
|||||||
pollTimer = null
|
pollTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTask(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -466,29 +528,12 @@ onBeforeUnmount(() => {
|
|||||||
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||||
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.loading-msg { margin-top: 10px; }
|
.loading-msg { margin-top: 10px; }
|
||||||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
|
.panel-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
|
||||||
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
|
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||||||
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||||
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
|
|
||||||
.files.error, .error { color: #ff6b6b; }
|
|
||||||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
|
||||||
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
|
||||||
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
|
|
||||||
.act-btn { padding: 5px 10px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
|
|
||||||
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
|
|
||||||
.act-btn.danger { background: rgba(231, 76, 60, 0.15); color: #ff8f8f; border-color: transparent; }
|
|
||||||
.task-progress { margin-top: 6px; max-width: 460px; }
|
|
||||||
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
|
|
||||||
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
|
|
||||||
.task-progress-fill { height: 100%; background: #3498db; transition: width 0.25s ease; }
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
|
|||||||
@@ -102,10 +102,10 @@
|
|||||||
:disabled="pushing || !lastTaskId"
|
:disabled="pushing || !lastTaskId"
|
||||||
@click="pushToPythonQueue"
|
@click="pushToPythonQueue"
|
||||||
>
|
>
|
||||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中...' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);推送到 Python 队列将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);启动任务将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
||||||
|
|
||||||
<div v-if="queuePushResult" class="queue-debug-card">
|
<div v-if="queuePushResult" class="queue-debug-card">
|
||||||
<div class="section-title queue-debug-title">推送结果</div>
|
<div class="section-title queue-debug-title">推送结果</div>
|
||||||
@@ -117,93 +117,26 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">采集数据</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="采集数据"
|
||||||
<div class="summary-card">
|
:cards="collectCards"
|
||||||
<span class="summary-label">运行中任务</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.pendingTaskCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无当前任务"
|
||||||
<span class="summary-label">已结束任务</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
>
|
||||||
</div>
|
<template #item-actions="{ item }">
|
||||||
<div class="summary-card">
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<span class="summary-label">成功任务</span>
|
</template>
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
<template #history-item-actions="{ item }">
|
||||||
</div>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div class="summary-card">
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
<span class="summary-label">失败任务</span>
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</TaskCenterPanel>
|
||||||
|
|
||||||
<div class="subsection-title">匹配任务</div>
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>当前任务</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
|
||||||
<div class="files">总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}</div>
|
|
||||||
<div class="files">不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}</div>
|
|
||||||
<div v-if="taskKeywordProgress(item)" class="files">{{ taskKeywordProgress(item) }}</div>
|
|
||||||
<div v-if="taskStageProgress(item)" class="files">{{ taskStageProgress(item) }}</div>
|
|
||||||
<div class="task-progress">
|
|
||||||
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
|
|
||||||
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status running">
|
|
||||||
{{ statusText(item) }}
|
|
||||||
<span v-if="taskFilterSummary(item)" class="status-filter-summary">({{ taskFilterSummary(item) }})</span>
|
|
||||||
</span>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>历史记录</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
|
||||||
<div class="files">总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}</div>
|
|
||||||
<div class="files">不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}</div>
|
|
||||||
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
<div class="task-progress">
|
|
||||||
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
|
|
||||||
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="statusClass(item)">
|
|
||||||
{{ statusText(item) }}
|
|
||||||
<span v-if="taskFilterSummary(item)" class="status-filter-summary">({{ taskFilterSummary(item) }})</span>
|
|
||||||
</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -215,6 +148,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
|
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
|
||||||
@@ -518,7 +453,7 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.enqueue_json) {
|
if (!api?.enqueue_json) {
|
||||||
ElMessage.warning('当前环境未提供 Python 队列能力')
|
ElMessage.warning('当前环境未提供任务队列能力')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pushing.value = true
|
pushing.value = true
|
||||||
@@ -549,12 +484,12 @@ async function pushToPythonQueue() {
|
|||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
throw new Error(result?.error || '入队失败')
|
throw new Error(result?.error || '入队失败')
|
||||||
}
|
}
|
||||||
queuePushResult.value = `已推送至 Python 队列(队列长度:${result.queue_size ?? '-'})`
|
queuePushResult.value = `已启动任务(队列长度:${result.queue_size ?? '-'})`
|
||||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||||
if (lastTaskId.value) {
|
if (lastTaskId.value) {
|
||||||
progressLoop.add(lastTaskId.value)
|
progressLoop.add(lastTaskId.value)
|
||||||
}
|
}
|
||||||
ElMessage.success('已推送到 Python 队列')
|
ElMessage.success('已启动任务')
|
||||||
await loadDashboard()
|
await loadDashboard()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activated && lastTaskId.value) {
|
if (activated && lastTaskId.value) {
|
||||||
@@ -746,6 +681,63 @@ async function deleteTaskRecord(item: CollectDataHistoryItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collectCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toCollectTaskView))
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historyItems.value.map(toCollectTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): CollectDataHistoryItem {
|
||||||
|
return item.source as CollectDataHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCollectTaskActive(item: CollectDataHistoryItem) {
|
||||||
|
const status = normalizeTaskStatus(item)
|
||||||
|
return status !== 'SUCCESS' && status !== 'FAILED' && !item.success
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCollectTaskView(item: CollectDataHistoryItem): TaskItemView {
|
||||||
|
const isActive = isCollectTaskActive(item)
|
||||||
|
const keywordProgress = taskKeywordProgress(item)
|
||||||
|
const stageProgress = taskStageProgress(item)
|
||||||
|
const percent = taskProgressPercent(item)
|
||||||
|
const finishedAt = item.finishedAt ? formatDateTime(item.finishedAt) : isActive ? '进行中' : '-'
|
||||||
|
const extraLines: string[] = isActive
|
||||||
|
? [
|
||||||
|
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
|
||||||
|
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
|
||||||
|
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
|
||||||
|
...(keywordProgress ? [keywordProgress] : []),
|
||||||
|
...(stageProgress ? [stageProgress] : []),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
|
||||||
|
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
|
||||||
|
...(item.resultFilename ? [item.resultFilename] : []),
|
||||||
|
...(taskFilterSummary(item) ? [`筛选统计:${taskFilterSummary(item)}`] : []),
|
||||||
|
...(item.error ? [`错误:${item.error}`] : []),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
key: `collect-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '采集数据',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt,
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines,
|
||||||
|
progress: isActive || percent > 0
|
||||||
|
? { percent, stage: '关键词进度' }
|
||||||
|
: null,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCountryPreference()
|
void loadCountryPreference()
|
||||||
void loadDashboard()
|
void loadDashboard()
|
||||||
@@ -759,6 +751,26 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -766,7 +778,7 @@ onBeforeUnmount(() => {
|
|||||||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||||||
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
|
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
|
||||||
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
|
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
|
||||||
.section-title, .subsection-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
|
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
|
||||||
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
|
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
|
||||||
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
@@ -831,35 +843,10 @@ onBeforeUnmount(() => {
|
|||||||
.queue-debug-line { color: #9ad; font-size: 12px; }
|
.queue-debug-line { color: #9ad; font-size: 12px; }
|
||||||
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #151a25; border-radius: 6px; color: #a0acbe; font-size: 11px; overflow: auto; max-height: 200px; }
|
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #151a25; border-radius: 6px; color: #a0acbe; font-size: 11px; overflow: auto; max-height: 200px; }
|
||||||
|
|
||||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
|
|
||||||
.summary-label { color: #5e6878; font-size: 12px; }
|
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
|
|
||||||
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
|
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 12px; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
|
|
||||||
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
|
|
||||||
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
|
|
||||||
.status-filter-summary { font-weight: 400; }
|
|
||||||
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
|
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
|
||||||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||||||
.task-progress { margin-top: 8px; max-width: 520px; }
|
|
||||||
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
|
|
||||||
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
|
|
||||||
.task-progress-fill { height: 100%; border-radius: inherit; background: #3498db; transition: width .25s ease; }
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -111,59 +111,37 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">转换结果</div>
|
<TaskCenterPanel
|
||||||
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="task-list-wrap">
|
title="转换结果"
|
||||||
<div class="clean-result-summary">
|
:cards="convertCards"
|
||||||
<div class="summary-card">
|
:current-items="currentTaskViews"
|
||||||
<span class="summary-label">已处理文件</span>
|
:history-items="historyTaskViews"
|
||||||
<strong>{{ convertSummary.total }}</strong>
|
current-title="当前任务"
|
||||||
</div>
|
current-empty-text="暂无当前任务,完成格式转换后会在这里展示"
|
||||||
<div class="summary-card">
|
history-empty-text="暂无历史记录"
|
||||||
<span class="summary-label">成功结果</span>
|
>
|
||||||
<strong>{{ convertSummary.successCount }}</strong>
|
<template #history-item-actions="{ item }">
|
||||||
</div>
|
<template v-if="itemSource(item)">
|
||||||
<div class="summary-card">
|
<button
|
||||||
<span class="summary-label">失败文件</span>
|
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||||
<strong>{{ convertSummary.failedCount }}</strong>
|
type="button"
|
||||||
</div>
|
class="download"
|
||||||
</div>
|
@click="downloadConvertResult(itemSource(item))"
|
||||||
|
>
|
||||||
<div class="result-list-wrap">
|
下载压缩包
|
||||||
<div class="result-list-header">
|
</button>
|
||||||
<span>生成的结果压缩包列表</span>
|
<button
|
||||||
</div>
|
v-if="itemSource(item).resultId"
|
||||||
|
type="button"
|
||||||
<div v-if="convertResultItems.length === 0" class="empty-tasks">
|
class="btn-delete"
|
||||||
暂无转换结果,完成格式转换后会在这里展示结果压缩包
|
@click="deleteConvertHistoryRecord(itemSource(item).resultId!)"
|
||||||
</div>
|
>
|
||||||
|
删除
|
||||||
<ul v-else class="task-list clean-result-list">
|
</button>
|
||||||
<li v-for="item in convertResultItems"
|
</template>
|
||||||
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
|
</template>
|
||||||
<div class="left">
|
</TaskCenterPanel>
|
||||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
|
||||||
<div v-if="item.outputFilename" class="files">压缩包文件:{{ item.outputFilename }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
|
||||||
{{ item.success ? '已完成' : '失败' }}
|
|
||||||
</span>
|
|
||||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
|
||||||
@click="downloadConvertResult(item)">
|
|
||||||
下载压缩包
|
|
||||||
</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteConvertHistoryRecord(item.resultId)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -174,6 +152,8 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { expandBrandFolderRecursive, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand'
|
||||||
import type { BrandExpandFolderItem } from '@/shared/api/brand'
|
import type { BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import {
|
import {
|
||||||
@@ -186,7 +166,6 @@ import {
|
|||||||
runConvert,
|
runConvert,
|
||||||
setDefaultConvertTemplate,
|
setDefaultConvertTemplate,
|
||||||
type ConvertResultItem,
|
type ConvertResultItem,
|
||||||
type ConvertRunVo,
|
|
||||||
type ConvertTemplateVo,
|
type ConvertTemplateVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
@@ -199,7 +178,6 @@ const convertArchiveName = ref('')
|
|||||||
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
|
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
|
||||||
const convertRunning = ref(false)
|
const convertRunning = ref(false)
|
||||||
const convertResultItems = ref<ConvertResultItem[]>([])
|
const convertResultItems = ref<ConvertResultItem[]>([])
|
||||||
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
|
||||||
const convertTemplates = ref<ConvertTemplateVo[]>([])
|
const convertTemplates = ref<ConvertTemplateVo[]>([])
|
||||||
const convertTemplateId = ref('')
|
const convertTemplateId = ref('')
|
||||||
const templateUploading = ref(false)
|
const templateUploading = ref(false)
|
||||||
@@ -215,6 +193,77 @@ const canDeleteCurrentTemplate = computed(
|
|||||||
() => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn),
|
() => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||||
|
const convertCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||||||
|
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||||||
|
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
function isConvertTaskBusy(item: ConvertResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
return status === 'RUNNING' || status === 'PENDING'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前任务:运行中/待执行项(转换接口通常同步完成即出结果,正常为空) */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
convertResultItems.value.filter(isConvertTaskBusy).map(toConvertTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 历史任务:全部已结束的结果项 */
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
convertResultItems.value.filter((item) => !isConvertTaskBusy(item)).map(toConvertTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): ConvertResultItem {
|
||||||
|
return item.source as ConvertResultItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTaskStatusText(item: ConvertResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'RUNNING') return '执行中'
|
||||||
|
if (status === 'PENDING') return '等待中'
|
||||||
|
return item.success ? '已完成' : '失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTaskStatusClass(item: ConvertResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'RUNNING') return 'running'
|
||||||
|
if (status === 'PENDING') return 'pending'
|
||||||
|
return item.success ? 'success' : 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConvertTaskView(item: ConvertResultItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: `convert-${item.resultId ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '格式转换',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: convertTaskStatusText(item),
|
||||||
|
statusClass: convertTaskStatusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
|
||||||
|
...(item.error ? [`错误信息:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.upload_file_to_java) {
|
if (!api?.upload_file_to_java) {
|
||||||
@@ -458,7 +507,6 @@ async function submitConvertRun() {
|
|||||||
? convertArchiveName.value
|
? convertArchiveName.value
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
convertSummary.value = result
|
|
||||||
convertResultItems.value = result.items || []
|
convertResultItems.value = result.items || []
|
||||||
await loadConvertHistory()
|
await loadConvertHistory()
|
||||||
if (result.total > 0 && result.successCount === 0) {
|
if (result.total > 0 && result.successCount === 0) {
|
||||||
@@ -485,12 +533,6 @@ async function loadConvertHistory() {
|
|||||||
try {
|
try {
|
||||||
const response = await getConvertHistory()
|
const response = await getConvertHistory()
|
||||||
convertResultItems.value = response.items || []
|
convertResultItems.value = response.items || []
|
||||||
convertSummary.value = {
|
|
||||||
total: response.items?.length || 0,
|
|
||||||
successCount: response.items?.filter((item) => item.success).length || 0,
|
|
||||||
failedCount: response.items?.filter((item) => !item.success).length || 0,
|
|
||||||
items: response.items || [],
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore history load errors
|
// ignore history load errors
|
||||||
}
|
}
|
||||||
@@ -526,6 +568,26 @@ onMounted(() => {
|
|||||||
loadConvertTemplates().catch(() => undefined)
|
loadConvertTemplates().catch(() => undefined)
|
||||||
loadConvertHistory().catch(() => undefined)
|
loadConvertHistory().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteConvertHistoryRecord(itemSource(view).resultId!)
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -832,81 +894,6 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -931,60 +918,16 @@ onMounted(() => {
|
|||||||
background: rgba(231, 76, 60, 0.22);
|
background: rgba(231, 76, 60, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tasks {
|
.files {
|
||||||
|
font-size: 12px;
|
||||||
color: #5e6878;
|
color: #5e6878;
|
||||||
font-size: 13px;
|
margin-top: 4px;
|
||||||
padding: 24px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-placeholder {
|
.clean-placeholder {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 220px));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1000,19 +943,5 @@ onMounted(() => {
|
|||||||
.right-panel {
|
.right-panel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -85,59 +85,29 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">去重结果</div>
|
<TaskCenterPanel
|
||||||
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="task-list-wrap">
|
title="去重结果"
|
||||||
<div class="clean-result-summary">
|
:cards="dedupeCards"
|
||||||
<div class="summary-card">
|
:current-items="currentTaskViews"
|
||||||
<span class="summary-label">已处理文件</span>
|
:history-items="historyTaskViews"
|
||||||
<strong>{{ cleanSummary.total }}</strong>
|
current-title="当前任务"
|
||||||
</div>
|
current-empty-text="暂无当前任务,完成数据去重后会在这里展示"
|
||||||
<div class="summary-card">
|
history-empty-text="暂无历史记录"
|
||||||
<span class="summary-label">成功结果</span>
|
>
|
||||||
<strong>{{ cleanSummary.successCount }}</strong>
|
<template #item-actions="{ item }">
|
||||||
</div>
|
<template v-if="itemSource(item)">
|
||||||
<div class="summary-card">
|
<button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||||
<span class="summary-label">失败文件</span>
|
type="button" class="download" @click="downloadCleanResult(itemSource(item))">
|
||||||
<strong>{{ cleanSummary.failedCount }}</strong>
|
下载文件
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
|
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
|
||||||
<div class="result-list-wrap">
|
删除
|
||||||
<div class="result-list-header">
|
</button>
|
||||||
<span>处理后的 Excel 列表</span>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
|
|
||||||
暂无去重结果,完成数据去重后会在这里展示输出文件
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in cleanResultItems"
|
|
||||||
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
|
||||||
<div v-if="item.outputFilename" class="files">输出文件:{{ item.outputFilename }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
|
||||||
{{ item.success ? '已完成' : '失败' }}
|
|
||||||
</span>
|
|
||||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
|
||||||
@click="downloadCleanResult(item)">
|
|
||||||
下载文件
|
|
||||||
</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteCleanHistoryRecord(item.resultId)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -148,6 +118,8 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
@@ -168,6 +140,88 @@ const cleanProgressProcessed = ref(0)
|
|||||||
const cleanResultItems = ref<DedupeResultItem[]>([])
|
const cleanResultItems = ref<DedupeResultItem[]>([])
|
||||||
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||||
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
|
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
|
||||||
|
// 最近一次去重运行信息:用于右侧"当前任务"卡的占位展示(开始时间/运行ID/进度)
|
||||||
|
const latestRunId = ref('')
|
||||||
|
const cleanRunStartedAt = ref('')
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): DedupeResultItem {
|
||||||
|
return item.source as DedupeResultItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: cleanSummary.value.total },
|
||||||
|
{ label: '成功任务', value: cleanSummary.value.successCount },
|
||||||
|
{ label: '失败任务', value: cleanSummary.value.failedCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 当前任务:运行中的去重 run(占位卡) + 历史列表中非终态任务 */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => {
|
||||||
|
const views: TaskItemView[] = []
|
||||||
|
if (cleanRunning.value && latestRunId.value) {
|
||||||
|
views.push({
|
||||||
|
key: `run-${latestRunId.value}`,
|
||||||
|
title: cleanArchiveName.value || '数据去重',
|
||||||
|
taskId: latestRunId.value,
|
||||||
|
startedAt: cleanRunStartedAt.value,
|
||||||
|
finishedAt: '',
|
||||||
|
statusText: '执行中',
|
||||||
|
statusClass: 'running',
|
||||||
|
progress: {
|
||||||
|
percent: cleanSummary.value.total
|
||||||
|
? Math.round((cleanProgressProcessed.value / cleanSummary.value.total) * 100)
|
||||||
|
: 0,
|
||||||
|
stage: `正在处理文件 ${cleanProgressProcessed.value}/${cleanSummary.value.total}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const item of cleanResultItems.value) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'RUNNING' || status === 'PENDING') {
|
||||||
|
views.push(toDedupeTaskView(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
})
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
cleanResultItems.value
|
||||||
|
.filter((item) => {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
return status !== 'RUNNING' && status !== 'PENDING'
|
||||||
|
})
|
||||||
|
.map(toDedupeTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function toDedupeTaskView(item: DedupeResultItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: `dedupe-${item.resultId ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: item.success ? '已完成' : '失败',
|
||||||
|
statusClass: item.success ? 'success' : 'failed',
|
||||||
|
extraLines: [
|
||||||
|
...(item.outputFilename ? [`输出文件:${item.outputFilename}`] : []),
|
||||||
|
...(item.error ? [`错误信息:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectAllCleanColumns() {
|
function selectAllCleanColumns() {
|
||||||
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
|
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
|
||||||
@@ -292,6 +346,8 @@ async function submitCleanRun() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
cleanRunning.value = true
|
cleanRunning.value = true
|
||||||
|
latestRunId.value = ''
|
||||||
|
cleanRunStartedAt.value = formatDateTime(new Date().toISOString())
|
||||||
const progress = await runDedupe({
|
const progress = await runDedupe({
|
||||||
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
|
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
|
||||||
selectedColumns: cleanSelectedColumns.value,
|
selectedColumns: cleanSelectedColumns.value,
|
||||||
@@ -312,6 +368,7 @@ async function submitCleanRun() {
|
|||||||
items: cleanResultItems.value,
|
items: cleanResultItems.value,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
latestRunId.value = progress.runId
|
||||||
cleanSummary.value = result
|
cleanSummary.value = result
|
||||||
cleanResultItems.value = result.items || []
|
cleanResultItems.value = result.items || []
|
||||||
await loadCleanHistory()
|
await loadCleanHistory()
|
||||||
@@ -410,6 +467,26 @@ async function downloadCleanResult(item: DedupeResultItem) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCleanHistory().catch(() => undefined)
|
loadCleanHistory().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteCleanHistoryRecord(itemSource(view).resultId!)
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -666,81 +743,6 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -775,50 +777,6 @@ onMounted(() => {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 220px));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -834,19 +792,5 @@ onMounted(() => {
|
|||||||
.right-panel {
|
.right-panel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
|
||||||
@click="pushToPythonQueue">
|
@click="pushToPythonQueue">
|
||||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中...' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="loading-msg">
|
<span class="loading-msg">
|
||||||
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
|
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
|
||||||
<div class="section-title queue-debug-title">Python 队列推送结果</div>
|
<div class="section-title queue-debug-title">任务启动结果</div>
|
||||||
<div v-if="queuePushResult" class="queue-debug-line">
|
<div v-if="queuePushResult" class="queue-debug-line">
|
||||||
{{ queuePushResult }}
|
{{ queuePushResult }}
|
||||||
</div>
|
</div>
|
||||||
@@ -77,135 +77,29 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">删除品牌结果</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="删除品牌结果"
|
||||||
<div class="summary-card">
|
:cards="deleteBrandCards"
|
||||||
<span class="summary-label">已处理文件</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ summary.total }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果"
|
||||||
<span class="summary-label">成功结果</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ summary.successCount }}</strong>
|
>
|
||||||
</div>
|
<template #item-actions="{ item }">
|
||||||
<div class="summary-card">
|
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
|
||||||
<span class="summary-label">失败文件</span>
|
@click="downloadTaskResult(itemSource(item))">下载</button>
|
||||||
<strong>{{ summary.failedCount }}</strong>
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
</div>
|
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
|
||||||
</div>
|
</template>
|
||||||
|
<template #history-item-actions="{ item }">
|
||||||
<div class="result-list-wrap">
|
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
|
||||||
<div class="result-list-header">
|
@click="downloadTaskResult(itemSource(item))">下载</button>
|
||||||
<span>删除品牌结果列表</span>
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
</div>
|
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
|
||||||
|
</template>
|
||||||
<div v-if="!hasVisibleItems" class="empty-tasks">
|
</TaskCenterPanel>
|
||||||
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">当前任务</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentSectionItems"
|
|
||||||
:key="`current-${item.taskId || item.resultId || item.sourceFilename}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
|
||||||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
|
||||||
<div class="files">匹配结果:{{ formatMatchResult(item) }}</div>
|
|
||||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
|
||||||
<div v-if="getQueueStatus(item)" class="files">队列状态:{{ getQueueStatus(item) }}</div>
|
|
||||||
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount
|
|
||||||
}}</div>
|
|
||||||
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} 条
|
|
||||||
</div>
|
|
||||||
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
|
|
||||||
<div class="delete-brand-progress-header">
|
|
||||||
<span>任务进度</span>
|
|
||||||
<span>{{ formatProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="delete-brand-progress-bar">
|
|
||||||
<div class="delete-brand-progress-bar-fill"
|
|
||||||
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
|
||||||
{{ formatPreview(item.previewRows) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="getDisplayError(item)" class="files">错误信息:{{ getDisplayError(item) }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="getTaskStatusInfo(item).className">
|
|
||||||
{{ getTaskStatusInfo(item).text }}
|
|
||||||
</span>
|
|
||||||
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
|
|
||||||
@click="downloadTaskResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteHistoryRecord(item.resultId)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史记录</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historySectionItems"
|
|
||||||
:key="`history-${item.taskId || item.resultId || item.sourceFilename}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
|
||||||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
|
||||||
<div class="files">匹配结果:{{ formatMatchResult(item) }}</div>
|
|
||||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
|
||||||
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount
|
|
||||||
}}</div>
|
|
||||||
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} 条
|
|
||||||
</div>
|
|
||||||
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
|
|
||||||
<div class="delete-brand-progress-header">
|
|
||||||
<span>任务进度</span>
|
|
||||||
<span>{{ formatProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="delete-brand-progress-bar">
|
|
||||||
<div class="delete-brand-progress-bar-fill"
|
|
||||||
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
|
||||||
{{ formatPreview(item.previewRows) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="getDisplayError(item)" class="files">错误信息:{{ getDisplayError(item) }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="getTaskStatusInfo(item).className">
|
|
||||||
{{ getTaskStatusInfo(item).text }}
|
|
||||||
</span>
|
|
||||||
<!-- 历史记录区不再显示打开紫鸟按钮 -->
|
|
||||||
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
|
|
||||||
@click="downloadTaskResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteHistoryRecord(item.resultId)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -217,6 +111,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import {
|
import {
|
||||||
@@ -403,11 +299,78 @@ const historySectionItems = computed(() =>
|
|||||||
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
|
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
|
||||||
const queueRunnableItems = computed(() =>
|
const queueRunnableItems = computed(() =>
|
||||||
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
|
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const deleteBrandCards = computed<TaskStatCard[]>(() => {
|
||||||
|
const historyRows = historySectionItems.value
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
for (const item of historyRows) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
successCount++
|
||||||
|
} else if (status === 'FAILED') {
|
||||||
|
failedCount++
|
||||||
|
} else if (item.success === true) {
|
||||||
|
successCount++
|
||||||
|
} else {
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: '运行中任务', value: currentSectionItems.value.length },
|
||||||
|
{ label: '已结束任务', value: historyRows.length },
|
||||||
|
{ label: '成功任务', value: successCount },
|
||||||
|
{ label: '失败任务', value: failedCount },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
currentSectionItems.value.map(toDeleteBrandTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
historySectionItems.value.map(toDeleteBrandTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): DeleteBrandResultItem {
|
||||||
|
return item.source as DeleteBrandResultItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDeleteBrandTaskView(item: DeleteBrandResultItem): TaskItemView {
|
||||||
|
const statusInfo = getTaskStatusInfo(item)
|
||||||
|
const showProgress = Boolean(shouldShowProgress(item))
|
||||||
|
const queueStatus = getQueueStatus(item)
|
||||||
|
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||||||
|
const detailStatus = (getTaskStatus(item.taskId) || '').toUpperCase()
|
||||||
|
const isTerminal = isTerminalStatus(taskStatus) || isTerminalStatus(detailStatus)
|
||||||
|
const extraLines: string[] = [
|
||||||
|
`店铺名:${item.shopName || '-'}`,
|
||||||
|
`匹配结果:${formatMatchResult(item)}`,
|
||||||
|
...(item.platform ? [`平台:${item.platform}`] : []),
|
||||||
|
...(!isTerminal && queueStatus ? [`队列状态:${queueStatus}`] : []),
|
||||||
|
...(item.countryCount !== undefined && item.matched ? [`国家数:${item.countryCount}`] : []),
|
||||||
|
...(item.totalRows !== undefined && item.matched ? [`去重后 ${item.totalRows} 条`] : []),
|
||||||
|
...(showProgress && formatProgress(item) ? [formatProgress(item)] : []),
|
||||||
|
...(item.previewRows?.length ? [formatPreview(item.previewRows)] : []),
|
||||||
|
...(getDisplayError(item) ? [`错误信息:${getDisplayError(item)}`] : []),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
key: `delete-brand-${item.taskId ?? ''}-${item.resultId ?? item.fileKey ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(taskCreatedAt(item)),
|
||||||
|
finishedAt: taskFinishedAt(item) ? formatDateTime(taskFinishedAt(item)) : '进行中',
|
||||||
|
statusText: statusInfo.text,
|
||||||
|
statusClass: statusInfo.className as TaskItemView['statusClass'],
|
||||||
|
extraLines,
|
||||||
|
progress: showProgress ? { percent: formatProgressPercent(item), stage: '任务进度' } : null,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persistSessionTasks() {
|
function persistSessionTasks() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
|
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
|
||||||
@@ -560,6 +523,32 @@ function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任务卡开始时间(任务详情快照的创建时间) */
|
||||||
|
function taskCreatedAt(item: DeleteBrandResultItem) {
|
||||||
|
if (!item.taskId) return ''
|
||||||
|
return taskDetails.value[item.taskId]?.task?.createdAt ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务卡结束时间(任务详情快照的完成时间) */
|
||||||
|
function taskFinishedAt(item: DeleteBrandResultItem) {
|
||||||
|
if (!item.taskId) return ''
|
||||||
|
return taskDetails.value[item.taskId]?.task?.finishedAt ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatMatchResult(item: DeleteBrandResultItem) {
|
function formatMatchResult(item: DeleteBrandResultItem) {
|
||||||
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
|
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
|
||||||
return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim()
|
return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim()
|
||||||
@@ -1135,7 +1124,7 @@ async function submitRun() {
|
|||||||
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
|
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
|
||||||
|
|
||||||
queuePushResult.value = hasRunnableItems
|
queuePushResult.value = hasRunnableItems
|
||||||
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
|
? '解析完成,可在左侧点击“启动任务”开始串行处理'
|
||||||
: '解析完成,当前没有可推送的文件'
|
: '解析完成,当前没有可推送的文件'
|
||||||
queuePayloadText.value = ''
|
queuePayloadText.value = ''
|
||||||
|
|
||||||
@@ -1147,9 +1136,9 @@ async function submitRun() {
|
|||||||
if (hasBlockedItems) {
|
if (hasBlockedItems) {
|
||||||
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
||||||
} else if (hasStaleMatchedItems) {
|
} else if (hasStaleMatchedItems) {
|
||||||
ElMessage.warning('部分文件命中的是过期索引,仍可推送到 Python 队列;后台刷新后会自动重新匹配。')
|
ElMessage.warning('部分文件命中的是过期索引,仍可启动任务;后台刷新后会自动重新匹配。')
|
||||||
} else if (hasRunnableItems) {
|
} else if (hasRunnableItems) {
|
||||||
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
|
ElMessage.success('删除品牌解析完成,请在左侧启动任务')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
|
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
|
||||||
}
|
}
|
||||||
@@ -1379,7 +1368,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
|
|||||||
return { success: true, retryable: false }
|
return { success: true, retryable: false }
|
||||||
} else {
|
} else {
|
||||||
const message = pushResult?.error || '未知错误'
|
const message = pushResult?.error || '未知错误'
|
||||||
qpr = `推送到 Python 队列失败:${message}`
|
qpr = `启动任务失败:${message}`
|
||||||
queuePushResult.value = qpr
|
queuePushResult.value = qpr
|
||||||
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
|
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
|
||||||
if (options?.auto && isQueueBusyError(message)) {
|
if (options?.auto && isQueueBusyError(message)) {
|
||||||
@@ -1407,7 +1396,7 @@ async function pushToPythonQueue() {
|
|||||||
activeItemKey.value = ''
|
activeItemKey.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('已开始按顺序推送到 Python 队列')
|
ElMessage.success('已开始按顺序启动任务')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeAutoAdvance() {
|
async function maybeAutoAdvance() {
|
||||||
@@ -1535,6 +1524,26 @@ onUnmounted(() => {
|
|||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteHistoryRecord(itemSource(view).resultId!)
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1754,154 +1763,12 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-item {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
.files {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #5e6878;
|
color: #5e6878;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-entry-list {
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-all;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-preview {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-progress-block {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-progress-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8fbfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-progress-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%);
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-brand-progress {
|
|
||||||
color: #69b6ff;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: normal;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.running {
|
|
||||||
background: rgba(52, 152, 219, 0.18);
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1926,60 +1793,10 @@ onUnmounted(() => {
|
|||||||
background: rgba(231, 76, 60, 0.22);
|
background: rgba(231, 76, 60, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tasks {
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 24px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-placeholder {
|
.clean-placeholder {
|
||||||
max-height: 112px;
|
max-height: 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 220px));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1995,25 +1812,6 @@ onUnmounted(() => {
|
|||||||
.right-panel {
|
.right-panel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-actions {
|
|
||||||
min-width: 0;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="section-title">店铺输入</div>
|
<div class="section-title">店铺输入</div>
|
||||||
<div class="input-zone">
|
<div class="input-zone">
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。任务只会跑下方勾选的国家。
|
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“启动任务”才会创建并执行任务。任务只会跑下方勾选的国家。
|
||||||
</div>
|
</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
:disabled="pushing || !matchedRunnableItems.length"
|
:disabled="pushing || !matchedRunnableItems.length"
|
||||||
@click="pushToPythonQueue"
|
@click="pushToPythonQueue"
|
||||||
>
|
>
|
||||||
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
|
{{ pushing ? "任务执行中..." : "启动任务" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,215 +154,85 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card">
|
:cards="patrolCards"
|
||||||
<span class="summary-label">备选店铺</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.candidateCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
|
current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
|
||||||
|
history-empty-text="暂无历史记录"
|
||||||
|
>
|
||||||
|
<template #cards-extra>
|
||||||
|
<div class="subsection-title">匹配结果</div>
|
||||||
|
<div v-if="!matchedItems.length" class="match-empty">
|
||||||
|
完成“匹配店铺”后,这里会展示匹配结果。
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<el-table
|
||||||
<span class="summary-label">已处理任务</span>
|
v-else
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
:data="matchedItems"
|
||||||
</div>
|
:row-key="rowKeyForMatch"
|
||||||
<div class="summary-card">
|
:highlight-current-row="false"
|
||||||
<span class="summary-label">成功任务</span>
|
class="result-table match-table"
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">失败任务</span>
|
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subsection-title">匹配结果</div>
|
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">
|
|
||||||
完成“匹配店铺”后,这里会展示匹配结果。
|
|
||||||
</div>
|
|
||||||
<el-table
|
|
||||||
v-else
|
|
||||||
:data="matchedItems"
|
|
||||||
:row-key="rowKeyForMatch"
|
|
||||||
:highlight-current-row="false"
|
|
||||||
class="result-table match-table"
|
|
||||||
>
|
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :class="row.matched ? 'ok' : 'fail'">
|
|
||||||
{{ row.matched ? "是" : "否" }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="shopId"
|
|
||||||
label="店铺 ID"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="platform"
|
|
||||||
label="平台"
|
|
||||||
width="88"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="companyName"
|
|
||||||
label="公司"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchStatus(row.matchStatus) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchRemark(row) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link-danger"
|
|
||||||
@click="removeMatchedRow(row)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>任务记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!currentSectionItems.length && !historySectionItems.length"
|
|
||||||
class="empty-tasks"
|
|
||||||
>
|
>
|
||||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||||
</div>
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
<template v-else>
|
<span :class="row.matched ? 'ok' : 'fail'">
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
{{ row.matched ? "是" : "否" }}
|
||||||
<div class="result-subsection-title">当前任务</div>
|
</span>
|
||||||
<ul class="task-list">
|
</template>
|
||||||
<li
|
</el-table-column>
|
||||||
v-for="item in currentSectionItems"
|
<el-table-column
|
||||||
:key="taskGroupKey(item)"
|
prop="shopId"
|
||||||
class="task-item"
|
label="店铺 ID"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="platform"
|
||||||
|
label="平台"
|
||||||
|
width="88"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="companyName"
|
||||||
|
label="公司"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchStatus(row.matchStatus) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchRemark(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-danger"
|
||||||
|
@click="removeMatchedRow(row)"
|
||||||
>
|
>
|
||||||
<div class="left split-result-main">
|
删除
|
||||||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
</button>
|
||||||
{{ formatTaskGroupShopNames(item) }}
|
</template>
|
||||||
</span>
|
</el-table-column>
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
</el-table>
|
||||||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
</template>
|
||||||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
<template #item-actions="{ item }">
|
||||||
</div>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
</template>
|
||||||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
<template #history-item-actions="{ item }">
|
||||||
</div>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div v-if="item.platform" class="files">
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
平台: {{ item.platform }}
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
<div class="files">
|
</TaskCenterPanel>
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
模板: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
|
||||||
错误: {{ formatTaskGroupErrors(item) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史任务</div>
|
|
||||||
<ul class="task-list">
|
|
||||||
<li
|
|
||||||
v-for="item in historySectionItems"
|
|
||||||
:key="taskGroupKey(item)"
|
|
||||||
class="task-item"
|
|
||||||
>
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
|
||||||
{{ formatTaskGroupShopNames(item) }}
|
|
||||||
</span>
|
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
|
||||||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
|
||||||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
|
||||||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.platform" class="files">
|
|
||||||
平台: {{ item.platform }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.finishedAt" class="files">
|
|
||||||
完成时间: {{ formatDateTime(item.finishedAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
模板: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
|
||||||
错误: {{ formatTaskGroupErrors(item) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="canDownload(item)"
|
|
||||||
type="button"
|
|
||||||
class="archive"
|
|
||||||
@click="downloadResult(item)"
|
|
||||||
>
|
|
||||||
下载结果
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -373,6 +243,8 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
|
||||||
|
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
|
||||||
import {
|
import {
|
||||||
addPatrolDeleteCandidate,
|
addPatrolDeleteCandidate,
|
||||||
addPatrolDeleteCondition,
|
addPatrolDeleteCondition,
|
||||||
@@ -456,6 +328,48 @@ const currentSectionItems = computed(() =>
|
|||||||
const historySectionItems = computed(() =>
|
const historySectionItems = computed(() =>
|
||||||
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 统一统计卡:运行中/已结束按页面当前分区统计,成功/失败按已结束任务的 taskStatus 统计 */
|
||||||
|
const patrolCards = computed<TaskStatCard[]>(() => {
|
||||||
|
const ended = historySectionItems.value
|
||||||
|
return [
|
||||||
|
{ label: '运行中任务', value: currentSectionItems.value.length },
|
||||||
|
{ label: '已结束任务', value: ended.length },
|
||||||
|
{
|
||||||
|
label: '成功任务',
|
||||||
|
value: ended.filter((item) => item.taskStatus === 'SUCCESS' || item.taskStatus === 'COMPLETED').length,
|
||||||
|
},
|
||||||
|
{ label: '失败任务', value: ended.filter((item) => item.taskStatus === 'FAILED').length },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPatrolTaskView))
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPatrolTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as PatrolDeleteHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPatrolTaskView(item: PatrolDeleteHistoryItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: taskGroupKey(item),
|
||||||
|
title: formatTaskGroupShopNames(item),
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.createdAt),
|
||||||
|
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '进行中',
|
||||||
|
statusText: statusText(item.taskStatus),
|
||||||
|
statusClass: statusClass(item.taskStatus),
|
||||||
|
extraLines: [
|
||||||
|
...(formatTaskGroupResultIds(item) ? [`结果 ID: ${formatTaskGroupResultIds(item)}`] : []),
|
||||||
|
...(formatTaskGroupShopIds(item) ? [`店铺 ID: ${formatTaskGroupShopIds(item)}`] : []),
|
||||||
|
...(item.platform ? [`平台: ${item.platform}`] : []),
|
||||||
|
`模板: ${formatTemplateSummary(item)}`,
|
||||||
|
...(formatTaskGroupErrors(item) ? [`错误: ${formatTaskGroupErrors(item)}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasQueueWork = computed(
|
const hasQueueWork = computed(
|
||||||
() =>
|
() =>
|
||||||
queueWorkerRunning.value ||
|
queueWorkerRunning.value ||
|
||||||
@@ -571,7 +485,7 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
|
|||||||
const message = (row.matchMessage || "").trim();
|
const message = (row.matchMessage || "").trim();
|
||||||
if (message) return message;
|
if (message) return message;
|
||||||
if (row.matched && row.matchStatus === "MATCHED") {
|
if (row.matched && row.matchStatus === "MATCHED") {
|
||||||
return "索引已命中,可推送到 Python 队列";
|
return "索引已命中,可启动任务";
|
||||||
}
|
}
|
||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已命中索引,请结合状态列确认";
|
return "已命中索引,请结合状态列确认";
|
||||||
@@ -1320,6 +1234,26 @@ onUnmounted(() => {
|
|||||||
clearSleepTimers();
|
clearSleepTimers();
|
||||||
timers.clearScope();
|
timers.clearScope();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1363,42 +1297,19 @@ onUnmounted(() => {
|
|||||||
.queue-debug-title { margin-bottom: 8px; }
|
.queue-debug-title { margin-bottom: 8px; }
|
||||||
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||||||
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
|
|
||||||
.summary-label { font-size: 12px; color: #5e6878; }
|
|
||||||
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||||
.empty-tasks.narrow { padding: 12px 8px; }
|
|
||||||
.match-table { margin-bottom: 18px; }
|
.match-table { margin-bottom: 18px; }
|
||||||
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
||||||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.ok { color: #27ae60; }
|
.ok { color: #27ae60; }
|
||||||
.fail { color: #e67e22; }
|
.fail { color: #e67e22; }
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
|
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||||||
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
|
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||||||
.result-subsection { padding: 12px 12px 4px; }
|
|
||||||
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
|
|
||||||
.files { font-size: 12px; color: #5e6878; }
|
|
||||||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
|
||||||
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
|
||||||
.archive:hover { background: rgba(52, 152, 219, 0.28); }
|
|
||||||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中…' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||||
@@ -152,109 +152,53 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary pr-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card">
|
:cards="priceTrackCards"
|
||||||
<span class="summary-label">备选店铺数</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.candidateCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
|
||||||
<div class="summary-card">
|
history-empty-text="暂无历史记录"
|
||||||
<span class="summary-label">已结束任务</span>
|
>
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
<template #cards-extra>
|
||||||
</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div class="summary-card">
|
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
||||||
<span class="summary-label">成功任务</span>
|
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
class="result-table match-table">
|
||||||
</div>
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<div class="summary-card">
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
<span class="summary-label">失败任务</span>
|
<template #default="{ row }">
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
|
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
||||||
<div class="subsection-title">匹配结果</div>
|
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成「匹配店铺」后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
<el-table-column label="状态" width="100" show-overflow-tooltip>
|
||||||
class="result-table match-table">
|
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
</el-table-column>
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
|
||||||
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
|
</el-table-column>
|
||||||
</template>
|
<el-table-column label="操作" width="72" align="center">
|
||||||
</el-table-column>
|
<template #default="{ row }">
|
||||||
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
</template>
|
||||||
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" show-overflow-tooltip>
|
</el-table>
|
||||||
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
|
</template>
|
||||||
</el-table-column>
|
<template #item-actions="{ item }">
|
||||||
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
</el-table-column>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<el-table-column label="操作" width="72" align="center">
|
</template>
|
||||||
<template #default="{ row }">
|
<template #history-item-actions="{ item }">
|
||||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
</template>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
</el-table-column>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</el-table>
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>处理记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录。推送队列并等待 Python 回传后,将在此显示下载链接。</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">当前任务</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史记录</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -267,6 +211,8 @@ import { ElMessage } from 'element-plus'
|
|||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||||
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
@@ -386,6 +332,25 @@ const historyItems = ref<PriceTrackHistoryItem[]>([])
|
|||||||
const pollingTaskIds = ref<number[]>([])
|
const pollingTaskIds = ref<number[]>([])
|
||||||
const taskDetails = ref<Record<number, string>>({})
|
const taskDetails = ref<Record<number, string>>({})
|
||||||
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
||||||
|
|
||||||
|
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
|
||||||
|
function snapTask(item: PriceTrackHistoryItem) {
|
||||||
|
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
|
||||||
|
}
|
||||||
|
|
||||||
const pollTimer = ref<number | null>(null)
|
const pollTimer = ref<number | null>(null)
|
||||||
const pollingInFlight = ref(false)
|
const pollingInFlight = ref(false)
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -1050,7 +1015,7 @@ async function pushToPythonQueueLegacy() {
|
|||||||
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
|
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
|
||||||
addPollingTask(taskVo.taskId)
|
addPollingTask(taskVo.taskId)
|
||||||
scheduleNextPoll(true)
|
scheduleNextPoll(true)
|
||||||
ElMessage.success('已推送到 Python 队列')
|
ElMessage.success('已启动任务')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||||
ElMessage.error(queuePushResult.value)
|
ElMessage.error(queuePushResult.value)
|
||||||
@@ -1097,7 +1062,7 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
|
|||||||
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
|
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
|
||||||
})
|
})
|
||||||
if (!(await passGuard(guard))) {
|
if (!(await passGuard(guard))) {
|
||||||
const reason = `任务 ${taskId} 数据校验未通过,已阻止推送到 Python 队列`
|
const reason = `任务 ${taskId} 数据校验未通过,已阻止启动任务`
|
||||||
await compensateDispatchFailure(taskId, reason)
|
await compensateDispatchFailure(taskId, reason)
|
||||||
throw new Error(reason)
|
throw new Error(reason)
|
||||||
}
|
}
|
||||||
@@ -1105,12 +1070,12 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
|
|||||||
try {
|
try {
|
||||||
pushResult = await api.enqueue_json(queuePayload)
|
pushResult = await api.enqueue_json(queuePayload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = `Python 队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
|
const reason = `任务队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
|
||||||
await compensateDispatchFailure(taskId, reason)
|
await compensateDispatchFailure(taskId, reason)
|
||||||
throw new Error(reason)
|
throw new Error(reason)
|
||||||
}
|
}
|
||||||
if (!pushResult?.success) {
|
if (!pushResult?.success) {
|
||||||
const reason = pushResult?.error || 'Python 队列拒绝接收任务'
|
const reason = pushResult?.error || '任务队列拒绝接收任务'
|
||||||
await compensateDispatchFailure(taskId, reason)
|
await compensateDispatchFailure(taskId, reason)
|
||||||
throw new Error(reason)
|
throw new Error(reason)
|
||||||
}
|
}
|
||||||
@@ -1650,7 +1615,40 @@ const historySectionItems = computed(() =>
|
|||||||
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
|
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
|
||||||
|
const priceTrackCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPriceTrackTaskView))
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPriceTrackTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as PriceTrackHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPriceTrackTaskView(item: PriceTrackHistoryItem): TaskItemView {
|
||||||
|
const task = snapTask(item)
|
||||||
|
return {
|
||||||
|
key: `follow-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
|
||||||
|
title: item.shopName || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(task?.createdAt),
|
||||||
|
finishedAt: formatDateTime(task?.finishedAt),
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.roundIndex ? [`轮次:第 ${item.roundIndex} 轮`] : []),
|
||||||
|
...(item.platform ? [`平台:${item.platform}`] : []),
|
||||||
|
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
|
||||||
|
...(item.error ? [`错误:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function taskStatusOf(taskId?: number) {
|
function taskStatusOf(taskId?: number) {
|
||||||
if (!taskId) return ''
|
if (!taskId) return ''
|
||||||
@@ -1770,6 +1768,26 @@ onUnmounted(() => {
|
|||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -2253,61 +2271,19 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Right panel */
|
/* Right panel */
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 20px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 22px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subsection-title {
|
.subsection-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #a0acbe;
|
color: #a0acbe;
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tasks {
|
/* 匹配结果空态(#cards-extra 区块内使用) */
|
||||||
|
.match-empty {
|
||||||
color: #5e6878;
|
color: #5e6878;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tasks.narrow {
|
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-table {
|
.match-table {
|
||||||
@@ -2325,96 +2301,6 @@ onUnmounted(() => {
|
|||||||
.ok { color: #27ae60; }
|
.ok { color: #27ae60; }
|
||||||
.fail { color: #e67e22; }
|
.fail { color: #e67e22; }
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 200px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-subsection {
|
|
||||||
padding: 12px 12px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-subsection-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.running {
|
|
||||||
background: rgba(52, 152, 219, 0.18);
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -2454,10 +2340,6 @@ onUnmounted(() => {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid #2e3a52;
|
border-bottom: 1px solid #2e3a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-summary {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<aside class="left-panel">
|
<aside class="left-panel">
|
||||||
<div class="section-title">店铺名称</div>
|
<div class="section-title">店铺名称</div>
|
||||||
<div class="input-zone">
|
<div class="input-zone">
|
||||||
<div class="hint">输入店铺名后点「确认」加入下方备选区(按当前登录用户保存)。仅索引可命中的店铺可入库。勾选备选后点「匹配店铺」,再「推送到 Python 队列」。</div>
|
<div class="hint">输入店铺名后点「确认」加入下方备选区(按当前登录用户保存)。仅索引可命中的店铺可入库。勾选备选后点「匹配店铺」,再「启动任务」。</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
|
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
|
||||||
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
|
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
||||||
|
|
||||||
<div class="section-title">商品列表筛选</div>
|
<div class="section-title">商品列表筛选</div>
|
||||||
<p class="hint listing-filter-hint">与「推送到 Python 队列」一并下发,供 Python 区分处理场景。</p>
|
<p class="hint listing-filter-hint">与「启动任务」一并下发,供 Python 区分处理场景。</p>
|
||||||
<div class="listing-filter-row">
|
<div class="listing-filter-row">
|
||||||
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
||||||
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
|
||||||
@click="pushToPythonQueue">
|
@click="pushToPythonQueue">
|
||||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中…' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">在搜索结果中禁止显示、详情页面已删除会把匹配店铺合并为一个任务入队,避免同时打开多个紫鸟窗口;其它筛选仍按店铺逐条入队。Python
|
<p class="loading-msg">在搜索结果中禁止显示、详情页面已删除会把匹配店铺合并为一个任务入队,避免同时打开多个紫鸟窗口;其它筛选仍按店铺逐条入队。Python
|
||||||
@@ -87,107 +87,53 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary pr-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card">
|
:cards="riskCards"
|
||||||
<span class="summary-label">备选店铺数</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.candidateCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
|
||||||
<div class="summary-card">
|
history-empty-text="暂无历史记录"
|
||||||
<span class="summary-label">已结束任务</span>
|
>
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
<template #cards-extra>
|
||||||
</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div class="summary-card">
|
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
||||||
<span class="summary-label">成功任务</span>
|
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
class="result-table match-table">
|
||||||
</div>
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<div class="summary-card">
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
<span class="summary-label">失败任务</span>
|
<template #default="{ row }">
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
|
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
||||||
<div class="subsection-title">匹配结果</div>
|
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成「匹配店铺」后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
<el-table-column label="状态" width="100" show-overflow-tooltip>
|
||||||
class="result-table match-table">
|
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
</el-table-column>
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
|
||||||
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
|
</el-table-column>
|
||||||
</template>
|
<el-table-column label="操作" width="72" align="center">
|
||||||
</el-table-column>
|
<template #default="{ row }">
|
||||||
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
</template>
|
||||||
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" show-overflow-tooltip>
|
</el-table>
|
||||||
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
|
</template>
|
||||||
</el-table-column>
|
<template #item-actions="{ item }">
|
||||||
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
</el-table-column>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<el-table-column label="操作" width="72" align="center">
|
</template>
|
||||||
<template #default="{ row }">
|
<template #history-item-actions="{ item }">
|
||||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
</template>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
</el-table-column>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</el-table>
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>处理记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录。推送队列并等待 Python 回传后,将在此显示下载链接。</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">当前任务</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史记录</div>
|
|
||||||
<ul class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
|
||||||
class="task-item split-result-item">
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -198,6 +144,8 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import {
|
import {
|
||||||
addProductRiskCandidate,
|
addProductRiskCandidate,
|
||||||
@@ -387,6 +335,25 @@ const pollingTaskIds = ref<number[]>([])
|
|||||||
const taskDetails = ref<Record<number, string>>({})
|
const taskDetails = ref<Record<number, string>>({})
|
||||||
/** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */
|
/** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */
|
||||||
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
|
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
|
||||||
|
|
||||||
|
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
|
||||||
|
function snapTask(item: ProductRiskHistoryItem) {
|
||||||
|
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
|
||||||
|
}
|
||||||
|
|
||||||
const pollTimer = ref<number | null>(null)
|
const pollTimer = ref<number | null>(null)
|
||||||
const pollingInFlight = ref(false)
|
const pollingInFlight = ref(false)
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -702,7 +669,39 @@ const historySectionItems = computed(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
/** 统一统计卡:运行中任务用页面当前列表实时计算,其余沿用后端 dashboard */
|
||||||
|
const riskCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toRiskTaskView))
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toRiskTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as ProductRiskHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRiskTaskView(item: ProductRiskHistoryItem): TaskItemView {
|
||||||
|
const task = snapTask(item)
|
||||||
|
return {
|
||||||
|
key: `risk-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
|
||||||
|
title: item.shopName || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(task?.createdAt),
|
||||||
|
finishedAt: formatDateTime(task?.finishedAt),
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.platform ? [`平台:${item.platform}`] : []),
|
||||||
|
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
|
||||||
|
...(item.error ? [`错误:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSIENT_BACKEND_ERROR_PATTERNS = [
|
const TRANSIENT_BACKEND_ERROR_PATTERNS = [
|
||||||
'无法连接到后端服务',
|
'无法连接到后端服务',
|
||||||
@@ -1267,6 +1266,26 @@ onUnmounted(() => {
|
|||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1577,61 +1596,19 @@ onUnmounted(() => {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 20px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 22px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subsection-title {
|
.subsection-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #a0acbe;
|
color: #a0acbe;
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tasks {
|
/* 匹配结果空态(#cards-extra 区块内使用) */
|
||||||
|
.match-empty {
|
||||||
color: #5e6878;
|
color: #5e6878;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tasks.narrow {
|
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-table {
|
.match-table {
|
||||||
@@ -1661,101 +1638,6 @@ onUnmounted(() => {
|
|||||||
color: #e67e22;
|
color: #e67e22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 200px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-subsection {
|
|
||||||
padding: 12px 12px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-subsection-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.running {
|
|
||||||
background: rgba(52, 152, 219, 0.18);
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1795,10 +1677,6 @@ onUnmounted(() => {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid #2e3a52;
|
border-bottom: 1px solid #2e3a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -91,110 +91,91 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">上架结果</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="上架结果"
|
||||||
<div class="summary-card">
|
:cards="publishCards"
|
||||||
<span class="summary-label">任务文件</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ summary.total }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无上架任务,完成文件选择后点击“开始上架”创建任务。"
|
||||||
<span class="summary-label">已处理文件</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ summary.completed }}</strong>
|
>
|
||||||
</div>
|
<template #item-extra="{ item }">
|
||||||
<div class="summary-card">
|
<ul class="publish-file-list">
|
||||||
<span class="summary-label">成功结果</span>
|
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
|
||||||
<strong>{{ summary.successCount }}</strong>
|
<div class="publish-file-row-head">
|
||||||
</div>
|
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
|
||||||
<div class="summary-card">
|
{{ file.sourceFilename || `文件 ${file.fileId}` }}
|
||||||
<span class="summary-label">失败文件</span>
|
|
||||||
<strong>{{ summary.failedCount }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>上架任务列表</span>
|
|
||||||
<span class="history-count">历史任务 {{ historyItems.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!visibleTasks.length" class="empty-tasks">
|
|
||||||
暂无上架任务,完成文件选择后点击“开始上架”创建任务。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-for="detail in visibleTasks"
|
|
||||||
v-else
|
|
||||||
:key="detail.task.id"
|
|
||||||
class="task-section"
|
|
||||||
>
|
|
||||||
<div class="task-section-header">
|
|
||||||
<div class="task-title-wrap">
|
|
||||||
<strong>上架任务 #{{ detail.task.id }}</strong>
|
|
||||||
<span class="task-meta">创建时间:{{ formatDateTime(detail.task.createdAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="task-actions">
|
|
||||||
<span class="status" :class="statusClass(detail.task.status)">
|
|
||||||
{{ statusText(detail.task.status) }}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
|
||||||
v-if="canDownload(detail)"
|
|
||||||
type="button"
|
|
||||||
class="download"
|
|
||||||
@click="downloadResult(detail)"
|
|
||||||
>
|
|
||||||
下载结果
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
:disabled="isDeletingTask(detail.task.id)"
|
|
||||||
@click="deleteTaskRecord(detail)"
|
|
||||||
>
|
|
||||||
{{ isDeletingTask(detail.task.id) ? '删除中...' : '删除' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="publish-file-row-info">
|
||||||
|
<span>店铺:{{ file.shopName || '-' }}</span>
|
||||||
<div v-if="detail.task.errorMessage" class="task-error">
|
<span>匹配:{{ matchText(file) }}</span>
|
||||||
{{ detail.task.errorMessage }}
|
<span v-if="file.platform">平台:{{ file.platform }}</span>
|
||||||
</div>
|
<span>数据:{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
|
||||||
|
</div>
|
||||||
<ul class="file-list">
|
<template v-if="shouldShowFileProgress(file)">
|
||||||
<li v-for="file in detail.files" :key="fileKey(file)" class="file-item">
|
<div class="publish-file-row-progress-meta">
|
||||||
<div class="file-main">
|
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
||||||
<div class="file-title" :title="file.sourceFilename || ''">
|
<span>{{ fileProgressPercent(file) }}%</span>
|
||||||
{{ file.sourceFilename || `文件 ${file.fileId}` }}
|
|
||||||
</div>
|
|
||||||
<div class="file-info">
|
|
||||||
<span>店铺:{{ file.shopName || '-' }}</span>
|
|
||||||
<span>匹配:{{ matchText(file) }}</span>
|
|
||||||
<span v-if="file.platform">平台:{{ file.platform }}</span>
|
|
||||||
<span>数据:{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="shouldShowFileProgress(file)">
|
|
||||||
<div class="file-progress-header">
|
|
||||||
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
|
||||||
<span>{{ fileProgressPercent(file) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-progress-track">
|
|
||||||
<div
|
|
||||||
class="file-progress-fill"
|
|
||||||
:class="statusClass(file.status)"
|
|
||||||
:style="{ width: `${fileProgressPercent(file)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="fileErrorText(file)" class="file-error">{{ fileErrorText(file) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="status file-status" :class="statusClass(file.status)">
|
<div class="publish-file-row-progress-track">
|
||||||
{{ statusText(file.status) }}
|
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
|
||||||
|
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<template #history-item-extra="{ item }">
|
||||||
|
<ul class="publish-file-list">
|
||||||
|
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
|
||||||
|
<div class="publish-file-row-head">
|
||||||
|
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
|
||||||
|
{{ file.sourceFilename || `文件 ${file.fileId}` }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
|
||||||
</ul>
|
</div>
|
||||||
</section>
|
<div class="publish-file-row-info">
|
||||||
</div>
|
<span>店铺:{{ file.shopName || '-' }}</span>
|
||||||
</div>
|
<span>匹配:{{ matchText(file) }}</span>
|
||||||
|
<span v-if="file.platform">平台:{{ file.platform }}</span>
|
||||||
|
<span>数据:{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="shouldShowFileProgress(file)">
|
||||||
|
<div class="publish-file-row-progress-meta">
|
||||||
|
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
||||||
|
<span>{{ fileProgressPercent(file) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-file-row-progress-track">
|
||||||
|
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
|
||||||
|
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<template #item-actions="{ item }">
|
||||||
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
|
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
|
||||||
|
@click="deleteTaskRecord(itemSource(item))">
|
||||||
|
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #history-item-actions="{ item }">
|
||||||
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
|
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
|
||||||
|
@click="deleteTaskRecord(itemSource(item))">
|
||||||
|
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -207,6 +188,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
|
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import {
|
import {
|
||||||
@@ -410,6 +393,65 @@ const visibleTasks = computed(() => {
|
|||||||
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
|
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isPublishTaskTerminal(detail: PublishTaskDetailVo) {
|
||||||
|
return isTerminalStatus(detail.task?.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishCards = computed<TaskStatCard[]>(() => {
|
||||||
|
const visible = visibleTasks.value
|
||||||
|
const terminal = visible.filter((detail) => isPublishTaskTerminal(detail))
|
||||||
|
let successCount = 0
|
||||||
|
for (const detail of terminal) {
|
||||||
|
const value = normalizeStatus(detail.task?.status)
|
||||||
|
if (value === 'SUCCESS' || value === 'COMPLETED') successCount += 1
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: '运行中任务', value: visible.length - terminal.length },
|
||||||
|
{ label: '已结束任务', value: terminal.length },
|
||||||
|
{ label: '成功任务', value: successCount },
|
||||||
|
{ label: '失败任务', value: terminal.length - successCount },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
visibleTasks.value
|
||||||
|
.filter((detail) => !isPublishTaskTerminal(detail))
|
||||||
|
.map(toPublishTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
historyItems.value
|
||||||
|
.filter((detail) => isPublishTaskTerminal(detail))
|
||||||
|
.map(toPublishTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): PublishTaskDetailVo {
|
||||||
|
return item.source as PublishTaskDetailVo
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublishTaskView(detail: PublishTaskDetailVo): TaskItemView {
|
||||||
|
const task = detail.task
|
||||||
|
const taskStatus = task?.status
|
||||||
|
const terminal = isTerminalStatus(taskStatus)
|
||||||
|
const rawPercent = Number(task?.percent || 0)
|
||||||
|
const percent = Number.isFinite(rawPercent) ? Math.max(0, Math.min(100, Math.round(rawPercent))) : 0
|
||||||
|
const errorMessage = task?.errorMessage || ''
|
||||||
|
const extraLines: string[] = []
|
||||||
|
if (errorMessage) extraLines.push(`错误:${errorMessage}`)
|
||||||
|
return {
|
||||||
|
key: `publish-${task?.id ?? 'unknown'}`,
|
||||||
|
title: `上架任务 #${task?.id ?? '-'}`,
|
||||||
|
taskId: task?.id ?? '-',
|
||||||
|
startedAt: formatDateTime(task?.startedAt || task?.createdAt),
|
||||||
|
finishedAt: task?.finishedAt ? formatDateTime(task.finishedAt) : terminal ? '-' : '进行中',
|
||||||
|
statusText: statusText(taskStatus),
|
||||||
|
statusClass: statusClass(taskStatus),
|
||||||
|
extraLines,
|
||||||
|
progress: !terminal && percent > 0 ? { percent, stage: '任务进度' } : null,
|
||||||
|
source: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = computed(() => {
|
const summary = computed(() => {
|
||||||
const files = visibleTasks.value.flatMap((detail) => detail.files || [])
|
const files = visibleTasks.value.flatMap((detail) => detail.files || [])
|
||||||
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
|
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
|
||||||
@@ -870,7 +912,7 @@ async function processQueue() {
|
|||||||
saveQueueState()
|
saveQueueState()
|
||||||
try {
|
try {
|
||||||
await activatePublishFile(taskId, nextFileId)
|
await activatePublishFile(taskId, nextFileId)
|
||||||
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' })
|
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已启动任务' })
|
||||||
const payload = buildQueuePayload(taskId, file)
|
const payload = buildQueuePayload(taskId, file)
|
||||||
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
|
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
|
||||||
const guard = checkQueuePayload(payload, {
|
const guard = checkQueuePayload(payload, {
|
||||||
@@ -882,7 +924,7 @@ async function processQueue() {
|
|||||||
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
|
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
|
||||||
}
|
}
|
||||||
const result = await api.enqueue_json(payload)
|
const result = await api.enqueue_json(payload)
|
||||||
if (!result?.success) throw new Error(result?.error || 'Python 队列拒绝接收任务')
|
if (!result?.success) throw new Error(result?.error || '任务队列拒绝接收任务')
|
||||||
queueMessage.value = pendingFileIds.value.length
|
queueMessage.value = pendingFileIds.value.length
|
||||||
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
|
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
|
||||||
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
|
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
|
||||||
@@ -1158,6 +1200,26 @@ onBeforeUnmount(() => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1167,8 +1229,8 @@ onBeforeUnmount(() => {
|
|||||||
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
|
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
|
||||||
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
|
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
|
||||||
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; }
|
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; }
|
||||||
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
.hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||||
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
.btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
.btns { justify-content: center; }
|
.btns { justify-content: center; }
|
||||||
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
|
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
|
||||||
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; }
|
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; }
|
||||||
@@ -1190,54 +1252,38 @@ onBeforeUnmount(() => {
|
|||||||
.btn-run:hover:not(:disabled) { background: #2980b9; }
|
.btn-run:hover:not(:disabled) { background: #2980b9; }
|
||||||
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
|
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
|
||||||
.queue-status-title { margin-bottom: 4px; }
|
.queue-status-title { margin-bottom: 4px; }
|
||||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 15px; font-weight: 600; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px; overflow-y: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
|
|
||||||
.summary-label { color: #5e6878; font-size: 12px; }
|
|
||||||
.result-list-wrap { min-height: 260px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
|
||||||
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
|
|
||||||
.empty-tasks { padding: 24px 16px; color: #5e6878; font-size: 13px; }
|
|
||||||
.task-section + .task-section { border-top: 1px solid #333f55; }
|
|
||||||
.task-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #222; }
|
|
||||||
.task-title-wrap { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: #c8d2e2; }
|
|
||||||
.task-error, .file-error { color: #ef8d8d; font-size: 12px; }
|
|
||||||
.task-error { padding: 10px 16px 0; }
|
|
||||||
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
|
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
|
||||||
.download:hover { background: #367d54; }
|
.download:hover { background: #367d54; }
|
||||||
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
|
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
|
||||||
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
|
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
|
||||||
.btn-delete:disabled { cursor: wait; opacity: .55; }
|
.btn-delete:disabled { cursor: wait; opacity: .55; }
|
||||||
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; min-height: 26px; padding: 0 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
|
.file-error { color: #ef8d8d; font-size: 12px; }
|
||||||
.status.pending { background: #3a3424; color: #e4c56a; }
|
.publish-file-list { margin: 0; padding: 0; list-style: none; }
|
||||||
.status.running { background: #24394a; color: #75bff1; }
|
.publish-file-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #2e3a52; }
|
||||||
.status.success { background: #233c2d; color: #72d598; }
|
.publish-file-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||||
.status.failed { background: #472929; color: #ef8d8d; }
|
.publish-file-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||||
.file-list { margin: 0; padding: 0; list-style: none; }
|
.publish-file-row-name { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.file-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid #292929; }
|
.publish-file-row-chip { flex-shrink: 0; padding: 2px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; }
|
||||||
.file-main { flex: 1; min-width: 0; }
|
.publish-file-row-chip.pending { background: #3a3424; color: #e4c56a; }
|
||||||
.file-title { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
.publish-file-row-chip.running { background: #24394a; color: #75bff1; }
|
||||||
.file-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
|
.publish-file-row-chip.success { background: #233c2d; color: #72d598; }
|
||||||
.file-progress-header { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; color: #969696; font-size: 11px; }
|
.publish-file-row-chip.failed { background: #472929; color: #ef8d8d; }
|
||||||
.file-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
|
.publish-file-row-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 5px; color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||||
.file-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
|
.publish-file-row-progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; color: #969696; font-size: 11px; }
|
||||||
.file-progress-fill.running { background: #409eff; }
|
.publish-file-row-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
|
||||||
.file-progress-fill.success { background: #42b36b; }
|
.publish-file-row-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
|
||||||
.file-progress-fill.failed { background: #d06161; }
|
.publish-file-row-progress-fill.running { background: #409eff; }
|
||||||
.file-error { margin-top: 7px; }
|
.publish-file-row-progress-fill.success { background: #42b36b; }
|
||||||
.file-status { flex-shrink: 0; }
|
.publish-file-row-progress-fill.failed { background: #d06161; }
|
||||||
|
.publish-file-row-error { margin-top: 7px; }
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { height: auto; flex-direction: column; }
|
.main-content { height: auto; flex-direction: column; }
|
||||||
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
|
||||||
.right-panel { min-height: 420px; }
|
.right-panel { min-height: 420px; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.clean-result-summary { grid-template-columns: 1fr; }
|
.publish-file-row-head { flex-direction: column; align-items: flex-start; }
|
||||||
.task-section-header, .file-item { flex-direction: column; }
|
|
||||||
.task-actions { width: 100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="section-title">店铺输入</div>
|
<div class="section-title">店铺输入</div>
|
||||||
<div class="input-zone">
|
<div class="input-zone">
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。查询ASIN按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。
|
左侧负责录入店铺并加入备选区,确认命中后启动任务。查询ASIN按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。
|
||||||
</div>
|
</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
||||||
@click="pushToPythonQueue"
|
@click="pushToPythonQueue"
|
||||||
>
|
>
|
||||||
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
|
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,221 +95,85 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card">
|
:cards="queryAsinCards"
|
||||||
<span class="summary-label">备选店铺</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.candidateCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
|
current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
|
||||||
|
history-empty-text="暂无历史记录"
|
||||||
|
>
|
||||||
|
<template #cards-extra>
|
||||||
|
<div class="subsection-title">匹配结果</div>
|
||||||
|
<div v-if="!matchedItems.length" class="match-empty">
|
||||||
|
完成“匹配店铺”后,这里会展示匹配结果。
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<el-table
|
||||||
<span class="summary-label">已处理任务</span>
|
v-else
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
:data="matchedItems"
|
||||||
</div>
|
:row-key="rowKeyForMatch"
|
||||||
<div class="summary-card">
|
:highlight-current-row="false"
|
||||||
<span class="summary-label">成功任务</span>
|
class="result-table match-table"
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">失败任务</span>
|
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subsection-title">匹配结果</div>
|
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">
|
|
||||||
完成“匹配店铺”后,这里会展示匹配结果。
|
|
||||||
</div>
|
|
||||||
<el-table
|
|
||||||
v-else
|
|
||||||
:data="matchedItems"
|
|
||||||
:row-key="rowKeyForMatch"
|
|
||||||
:highlight-current-row="false"
|
|
||||||
class="result-table match-table"
|
|
||||||
>
|
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :class="row.matched ? 'ok' : 'fail'">
|
|
||||||
{{ row.matched ? "是" : "否" }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="shopId"
|
|
||||||
label="店铺 ID"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="platform"
|
|
||||||
label="平台"
|
|
||||||
width="88"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="companyName"
|
|
||||||
label="公司"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchStatus(row.matchStatus) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchRemark(row) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link-danger"
|
|
||||||
@click="removeMatchedRow(row)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>任务记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!currentSectionItems.length && !historySectionItems.length"
|
|
||||||
class="empty-tasks"
|
|
||||||
>
|
>
|
||||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||||
</div>
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
<template v-else>
|
<span :class="row.matched ? 'ok' : 'fail'">
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
{{ row.matched ? "是" : "否" }}
|
||||||
<div class="result-subsection-title">当前任务</div>
|
</span>
|
||||||
<ul class="task-list">
|
</template>
|
||||||
<li
|
</el-table-column>
|
||||||
v-for="item in currentSectionItems"
|
<el-table-column
|
||||||
:key="`${item.resultId}-${item.taskId}`"
|
prop="shopId"
|
||||||
class="task-item"
|
label="店铺 ID"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="platform"
|
||||||
|
label="平台"
|
||||||
|
width="88"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="companyName"
|
||||||
|
label="公司"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchStatus(row.matchStatus) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchRemark(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-danger"
|
||||||
|
@click="removeMatchedRow(row)"
|
||||||
>
|
>
|
||||||
<div class="left split-result-main">
|
删除
|
||||||
<span class="id" :title="item.shopName || ''">
|
</button>
|
||||||
{{ item.shopName || "-" }}
|
</template>
|
||||||
</span>
|
</el-table-column>
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
</el-table>
|
||||||
<div v-if="item.resultId" class="files">
|
</template>
|
||||||
结果 ID: {{ item.resultId }}
|
<template #item-actions="{ item }">
|
||||||
</div>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<div v-if="item.shopId" class="files">
|
</template>
|
||||||
店铺 ID: {{ item.shopId }}
|
<template #history-item-actions="{ item }">
|
||||||
</div>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div v-if="item.platform" class="files">
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
平台: {{ item.platform }}
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
<div class="files">
|
</TaskCenterPanel>
|
||||||
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
ASIN结构: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">
|
|
||||||
错误: {{ item.error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史任务</div>
|
|
||||||
<ul class="task-list">
|
|
||||||
<li
|
|
||||||
v-for="item in historySectionItems"
|
|
||||||
:key="`${item.resultId}-${item.taskId}`"
|
|
||||||
class="task-item"
|
|
||||||
>
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.shopName || ''">
|
|
||||||
{{ item.shopName || "-" }}
|
|
||||||
</span>
|
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
|
||||||
<div v-if="item.resultId" class="files">
|
|
||||||
结果 ID: {{ item.resultId }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.shopId" class="files">
|
|
||||||
店铺 ID: {{ item.shopId }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.platform" class="files">
|
|
||||||
平台: {{ item.platform }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.finishedAt" class="files">
|
|
||||||
完成时间: {{ formatDateTime(item.finishedAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
ASIN结构: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">
|
|
||||||
错误: {{ item.error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="canDownload(item)"
|
|
||||||
type="button"
|
|
||||||
class="archive"
|
|
||||||
@click="downloadResult(item)"
|
|
||||||
>
|
|
||||||
下载结果
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -320,6 +184,8 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
|
||||||
|
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
|
||||||
import {
|
import {
|
||||||
addQueryAsinCandidate,
|
addQueryAsinCandidate,
|
||||||
createQueryAsinTask,
|
createQueryAsinTask,
|
||||||
@@ -386,6 +252,44 @@ const currentSectionItems = computed(() =>
|
|||||||
const historySectionItems = computed(() =>
|
const historySectionItems = computed(() =>
|
||||||
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
|
||||||
|
const queryAsinCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentSectionItems.value.length },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toQueryAsinTaskView));
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toQueryAsinTaskView));
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as QueryAsinHistoryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQueryAsinTaskView(item: QueryAsinHistoryItem): TaskItemView {
|
||||||
|
const startedRaw = taskStartTime(item.taskId);
|
||||||
|
return {
|
||||||
|
key: `qasin-${item.taskId ?? "r"}-${item.resultId ?? ""}-${item.shopName ?? ""}`,
|
||||||
|
title: item.shopName || "-",
|
||||||
|
taskId: item.taskId ?? "-",
|
||||||
|
startedAt: formatDateTime(startedRaw || item.createdAt),
|
||||||
|
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : "进行中",
|
||||||
|
statusText: statusText(item.taskStatus),
|
||||||
|
statusClass: statusClass(item.taskStatus),
|
||||||
|
extraLines: [
|
||||||
|
...(item.resultId ? [`结果 ID: ${item.resultId}`] : []),
|
||||||
|
...(item.shopId ? [`店铺 ID: ${item.shopId}`] : []),
|
||||||
|
...(item.platform ? [`平台: ${item.platform}`] : []),
|
||||||
|
...(startedRaw && item.createdAt && startedRaw !== item.createdAt ? [`创建时间: ${formatDateTime(item.createdAt)}`] : []),
|
||||||
|
`ASIN结构: ${formatTemplateSummary(item)}`,
|
||||||
|
...(item.error ? [`错误: ${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const hasQueuedTaskWork = computed(
|
const hasQueuedTaskWork = computed(
|
||||||
() =>
|
() =>
|
||||||
!!activeTaskId.value ||
|
!!activeTaskId.value ||
|
||||||
@@ -499,7 +403,7 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
|
|||||||
const message = (row.matchMessage || "").trim();
|
const message = (row.matchMessage || "").trim();
|
||||||
if (message) return message;
|
if (message) return message;
|
||||||
if (row.matched && row.matchStatus === "MATCHED") {
|
if (row.matched && row.matchStatus === "MATCHED") {
|
||||||
return "索引已命中,可推送到 Python 队列";
|
return "索引已命中,可启动任务";
|
||||||
}
|
}
|
||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已命中索引,请结合状态列确认";
|
return "已命中索引,请结合状态列确认";
|
||||||
@@ -1206,6 +1110,26 @@ onUnmounted(() => {
|
|||||||
clearSleepTimers();
|
clearSleepTimers();
|
||||||
timers.clearScope();
|
timers.clearScope();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1237,43 +1161,20 @@ onUnmounted(() => {
|
|||||||
.queue-debug-title { margin-bottom: 8px; }
|
.queue-debug-title { margin-bottom: 8px; }
|
||||||
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||||||
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
|
|
||||||
.summary-label { font-size: 12px; color: #5e6878; }
|
|
||||||
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||||
.empty-tasks.narrow { padding: 12px 8px; }
|
|
||||||
.match-table { margin-bottom: 18px; }
|
.match-table { margin-bottom: 18px; }
|
||||||
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
||||||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.ok { color: #27ae60; }
|
.ok { color: #27ae60; }
|
||||||
.fail { color: #e67e22; }
|
.fail { color: #e67e22; }
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
|
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||||||
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
|
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||||||
.result-subsection { padding: 12px 12px 4px; }
|
|
||||||
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
|
|
||||||
.files { font-size: 12px; color: #5e6878; }
|
|
||||||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
|
||||||
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
|
||||||
.archive:hover { background: rgba(52, 152, 219, 0.28); }
|
|
||||||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -66,52 +66,51 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card"><span class="summary-label">备选店铺</span><strong>{{ dashboard.candidateCount }}</strong></div>
|
:cards="shopDataCards"
|
||||||
<div class="summary-card"><span class="summary-label">已处理任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div>
|
:current-items="currentTaskViews"
|
||||||
<div class="summary-card"><span class="summary-label">成功任务</span><strong>{{ dashboard.successTaskCount }}</strong></div>
|
:history-items="historyTaskViews"
|
||||||
<div class="summary-card"><span class="summary-label">失败任务</span><strong>{{ dashboard.failedTaskCount }}</strong></div>
|
current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
|
||||||
</div>
|
history-empty-text="暂无历史记录"
|
||||||
|
>
|
||||||
<div class="subsection-title">匹配结果</div>
|
<template #cards-extra>
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">匹配后将在这里显示结果</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
<div v-if="!matchedItems.length" class="match-empty">匹配后将在这里显示结果</div>
|
||||||
class="result-table match-table">
|
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
class="result-table match-table">
|
||||||
<el-table-column label="匹配" width="64" align="center">
|
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||||
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
|
<el-table-column label="匹配" width="64" align="center">
|
||||||
</el-table-column>
|
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
|
||||||
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
|
</el-table-column>
|
||||||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||||||
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
|
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
||||||
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
|
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
|
||||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
|
||||||
</template></el-table-column>
|
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||||
</el-table>
|
</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
<div class="result-list-wrap">
|
</template>
|
||||||
<div class="result-list-header"><span>任务记录</span></div>
|
<template #item-extra="{ item }">
|
||||||
<div v-if="!currentItems.length && !historySectionItems.length" class="empty-tasks">暂无任务记录</div>
|
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
||||||
<div v-if="currentItems.length" class="result-subsection">
|
</template>
|
||||||
<div class="result-subsection-title">当前任务</div>
|
<template #history-item-extra="{ item }">
|
||||||
<ul class="task-list">
|
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
||||||
<TaskRow v-for="item in currentItems" :key="historyKey(item)" :item="item"
|
</template>
|
||||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
<template #item-actions="{ item }">
|
||||||
</ul>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
</div>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<div class="result-subsection-title">历史记录</div>
|
</template>
|
||||||
<ul class="task-list">
|
<template #history-item-actions="{ item }">
|
||||||
<TaskRow v-for="item in historySectionItems" :key="historyKey(item)" :item="item"
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
</ul>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</TaskCenterPanel>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -119,9 +118,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
@@ -160,28 +161,6 @@ const COUNTRY_OPTIONS = [
|
|||||||
{ code: 'IT', label: '意大利' },
|
{ code: 'IT', label: '意大利' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const TaskRow = defineComponent({
|
|
||||||
props: { item: { type: Object as () => ShopDataCrawlHistoryItem, required: true } },
|
|
||||||
emits: ['download', 'delete'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
return () => h('li', { class: 'task-item' }, [
|
|
||||||
h('div', { class: 'left split-result-main' }, [
|
|
||||||
h('span', { class: 'id', title: props.item.shopName || '' }, props.item.shopName || '-'),
|
|
||||||
h('div', { class: 'files' }, `任务 ID:${props.item.taskId ?? '-'}`),
|
|
||||||
props.item.platform ? h('div', { class: 'files' }, `平台:${props.item.platform}`) : null,
|
|
||||||
props.item.outputFilename ? h('div', { class: 'files' }, `文件:${props.item.outputFilename}`) : null,
|
|
||||||
props.item.createdAt ? h('div', { class: 'files' }, `创建时间:${formatDateTime(props.item.createdAt)}`) : null,
|
|
||||||
props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null,
|
|
||||||
]),
|
|
||||||
h('div', { class: 'task-right' }, [
|
|
||||||
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item)),
|
|
||||||
canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null,
|
|
||||||
h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const timers = createCategorizedTimers('shop-data-crawl')
|
const timers = createCategorizedTimers('shop-data-crawl')
|
||||||
const ziniaoVersion = useZiniaoVersion()
|
const ziniaoVersion = useZiniaoVersion()
|
||||||
const shopInput = ref('')
|
const shopInput = ref('')
|
||||||
@@ -213,6 +192,39 @@ let disposed = false
|
|||||||
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
|
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
|
||||||
const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus)))
|
const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus)))
|
||||||
const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus)))
|
const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus)))
|
||||||
|
|
||||||
|
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
|
||||||
|
const shopDataCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentItems.value.length },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toShopDataCrawlTaskView))
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopDataCrawlTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as ShopDataCrawlHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toShopDataCrawlTaskView(item: ShopDataCrawlHistoryItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: historyKey(item),
|
||||||
|
title: item.shopName || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.createdAt),
|
||||||
|
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '',
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item.taskStatus),
|
||||||
|
extraLines: [
|
||||||
|
...(item.platform ? [`平台:${item.platform}`] : []),
|
||||||
|
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0)
|
const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0)
|
||||||
const countryCheckboxRows = computed(() => {
|
const countryCheckboxRows = computed(() => {
|
||||||
const selected = new Set(orderedCountryCodes.value)
|
const selected = new Set(orderedCountryCodes.value)
|
||||||
@@ -566,20 +578,40 @@ onUnmounted(() => {
|
|||||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.module-page { min-height: 100vh; background: #151a25; }
|
.module-page { min-height: 100vh; background: #151a25; }
|
||||||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||||||
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; }
|
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; }
|
||||||
.right-panel { flex: 1; min-width: 0; background: #151a25; }
|
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
|
||||||
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
|
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
|
||||||
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
|
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
|
||||||
.input-row { display: flex; gap: 10px; }
|
.input-row { display: flex; gap: 10px; }
|
||||||
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; }
|
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; }
|
||||||
.opt-btn { flex: 0 0 auto; }
|
.opt-btn { flex: 0 0 auto; }
|
||||||
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
|
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
.empty-candidates, .empty-tasks { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
|
.empty-candidates { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
|
||||||
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; }
|
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; }
|
||||||
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
|
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
|
||||||
@@ -595,25 +627,14 @@ onUnmounted(() => {
|
|||||||
.country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; }
|
.country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; }
|
||||||
.run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
.run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||||
.btn-queue { background: #67c23a; }
|
.btn-queue { background: #67c23a; }
|
||||||
.panel-header { height: 52px; padding: 16px 22px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 700; }
|
.subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
|
||||||
.task-list-wrap { height: calc(100% - 52px); padding: 18px 22px 28px; overflow-y: auto; }
|
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 16px; text-align: center; }
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
||||||
.summary-card { display: flex; min-height: 70px; flex-direction: column; justify-content: center; padding: 12px 16px; border: 1px solid #333f55; border-radius: 6px; background: #222; }
|
|
||||||
.summary-label { margin-bottom: 4px; color: #5e6878; font-size: 12px; }
|
|
||||||
.summary-card strong { color: #f5f8fc; font-size: 22px; }
|
|
||||||
.subsection-title, .result-subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
|
|
||||||
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.ok { color: #67c23a; }.fail, .error-text { color: #f56c6c; }
|
.ok { color: #67c23a; }
|
||||||
.result-list-header { padding: 13px 0; border-bottom: 1px solid #333f55; color: #f5f8fc; font-weight: 700; }
|
.files { font-size: 12px; color: #5e6878; }
|
||||||
.task-list { margin: 0; padding: 0; list-style: none; }
|
.fail, .error-text { color: #f56c6c; }
|
||||||
:deep(.task-item) { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 84px; padding: 14px 0; border-bottom: 1px solid #2b2b2b; }
|
.download, .btn-delete { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
|
||||||
:deep(.split-result-main) { min-width: 0; }
|
.download { border-color: #409eff; color: #8dc4ff; }
|
||||||
:deep(.id) { display: block; margin-bottom: 5px; color: #f5f8fc; font-weight: 700; }
|
.btn-delete { color: #f56c6c; }
|
||||||
:deep(.files) { margin-top: 3px; color: #5e6878; font-size: 12px; }
|
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; } }
|
||||||
:deep(.task-right) { display: flex; align-items: center; gap: 10px; }
|
|
||||||
:deep(.status) { min-width: 88px; font-size: 12px; text-align: center; white-space: nowrap; }
|
|
||||||
:deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; }
|
|
||||||
:deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
|
|
||||||
:deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }
|
|
||||||
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; }.clean-result-summary { grid-template-columns: repeat(2, 1fr); } }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<aside class="left-panel">
|
<aside class="left-panel">
|
||||||
<div class="section-title">店铺候选</div>
|
<div class="section-title">店铺候选</div>
|
||||||
<div class="input-zone">
|
<div class="input-zone">
|
||||||
<div class="hint">输入店铺名后点“添加”加入下方备选区。勾选备选店铺后先做“匹配店铺”,确认命中紫鸟索引后再推送到 Python 队列。</div>
|
<div class="hint">输入店铺名后点“添加”加入下方备选区。勾选备选店铺后先做“匹配店铺”,确认命中紫鸟索引后再启动任务。</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
|
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
|
||||||
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
|
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
|
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
|
||||||
<div class="section-title">商品列表筛选</div>
|
<div class="section-title">商品列表筛选</div>
|
||||||
<p class="hint listing-filter-hint">与“推送到 Python 队列”一并下发,供 Python 区分处理场景。</p>
|
<p class="hint listing-filter-hint">与“启动任务”一并下发,供 Python 区分处理场景。</p>
|
||||||
<div class="listing-filter-row">
|
<div class="listing-filter-row">
|
||||||
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
||||||
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
@@ -59,47 +59,48 @@
|
|||||||
|
|
||||||
<div class="run-row">
|
<div class="run-row">
|
||||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
|
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '推送中...' : scheduleEnabled ? '创建定时任务' : '推送到 Python 队列' }}</button>
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '启动中...' : scheduleEnabled ? '创建定时任务' : '启动任务' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">已支持多阶段定时执行;Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>,后端会按阶段汇总并在超时场景自动收尾。</p>
|
<p class="loading-msg">已支持多阶段定时执行;Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>,后端会按阶段汇总并在超时场景自动收尾。</p>
|
||||||
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div>
|
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="匹配与任务"
|
||||||
<div class="summary-card"><span class="summary-label">候选店铺</span><strong>{{ dashboard.candidateCount }}</strong></div>
|
:cards="shopMatchCards"
|
||||||
<div class="summary-card"><span class="summary-label">已结束任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div>
|
:current-items="currentTaskViews"
|
||||||
<div class="summary-card"><span class="summary-label">成功</span><strong>{{ dashboard.successTaskCount }}</strong></div>
|
:history-items="historyTaskViews"
|
||||||
<div class="summary-card"><span class="summary-label">失败</span><strong>{{ dashboard.failedTaskCount }}</strong></div>
|
current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
|
||||||
</div>
|
history-empty-text="暂无历史记录"
|
||||||
<div class="subsection-title">匹配结果</div>
|
>
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成“匹配店铺”后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
<template #cards-extra>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
|
<div class="subsection-title">匹配结果</div>
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
<div v-if="!matchedItems.length" class="match-empty">完成“匹配店铺”后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
||||||
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
|
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
|
||||||
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
|
||||||
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||||||
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
|
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
|
<el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
||||||
</el-table>
|
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
|
||||||
<div class="result-list-wrap">
|
<el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
|
||||||
<div class="result-list-header"><span>处理记录</span></div>
|
</el-table>
|
||||||
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录。推送队列并等待 Python 回传后,将在此显示下载链接。</div>
|
</template>
|
||||||
<template v-else>
|
<template #item-extra="{ item }">
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
<div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
|
||||||
<div class="result-subsection-title">当前任务</div>
|
<div v-if="nextScheduledDisplay(itemSource(item))" class="files">下一次: {{ nextScheduledDisplay(itemSource(item)) }}</div>
|
||||||
<ul class="task-list"><li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.platform" class="files">平台: {{ item.platform }}</div><div v-if="currentTaskStageText(item)" class="files">{{ currentTaskStageText(item) }}</div><div v-if="nextScheduledDisplay(item)" class="files">下一次: {{ nextScheduledDisplay(item) }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul>
|
</template>
|
||||||
</div>
|
<template #item-actions="{ item }">
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
<button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
|
||||||
<div class="result-subsection-title">历史任务</div>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<ul class="task-list"><li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.outputFilename" class="files">文件: {{ item.outputFilename }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul>
|
</template>
|
||||||
</div>
|
<template #history-item-actions="{ item }">
|
||||||
</template>
|
<button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
|
||||||
</div>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -110,6 +111,8 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||||
@@ -145,6 +148,11 @@ const historyItems = ref<ShopMatchHistoryItem[]>([])
|
|||||||
const pollingTaskIds = ref<number[]>([])
|
const pollingTaskIds = ref<number[]>([])
|
||||||
const taskDetails = ref<Record<number, string>>({})
|
const taskDetails = ref<Record<number, string>>({})
|
||||||
const taskSnapshots = ref<Record<number, ShopMatchTaskDetailVo>>({})
|
const taskSnapshots = ref<Record<number, ShopMatchTaskDetailVo>>({})
|
||||||
|
|
||||||
|
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
|
||||||
|
function snapTask(item: ShopMatchHistoryItem) {
|
||||||
|
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
|
||||||
|
}
|
||||||
const pollTimer = ref<number | null>(null)
|
const pollTimer = ref<number | null>(null)
|
||||||
const pollingInFlight = ref(false)
|
const pollingInFlight = ref(false)
|
||||||
const dispatchTimers = new Map<number, number>()
|
const dispatchTimers = new Map<number, number>()
|
||||||
@@ -156,7 +164,40 @@ let disposed = false
|
|||||||
const timers = createCategorizedTimers('shop-match')
|
const timers = createCategorizedTimers('shop-match')
|
||||||
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
|
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
|
||||||
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
|
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
|
||||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
|
||||||
|
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
|
||||||
|
const shopMatchCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toShopMatchTaskView))
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopMatchTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView) {
|
||||||
|
return item.source as ShopMatchHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toShopMatchTaskView(item: ShopMatchHistoryItem): TaskItemView {
|
||||||
|
const task = snapTask(item)
|
||||||
|
return {
|
||||||
|
key: taskRowKey(item),
|
||||||
|
title: item.shopName || '-',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(task?.createdAt),
|
||||||
|
finishedAt: formatDateTime(task?.finishedAt),
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.platform ? [`平台: ${item.platform}`] : []),
|
||||||
|
...(item.outputFilename ? [`文件: ${item.outputFilename}`] : []),
|
||||||
|
...(item.error ? [`错误: ${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] })
|
const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] })
|
||||||
|
|
||||||
function uidForStorage() { return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' }
|
function uidForStorage() { return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' }
|
||||||
@@ -507,6 +548,26 @@ function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.
|
|||||||
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||||
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
|
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -560,39 +621,18 @@ onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); cle
|
|||||||
.queue-debug-title { margin-bottom: 8px; }
|
.queue-debug-title { margin-bottom: 8px; }
|
||||||
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||||||
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
|
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
|
|
||||||
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
|
|
||||||
.summary-label { font-size: 12px; color: #5e6878; }
|
|
||||||
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||||
.empty-tasks.narrow { padding: 12px 8px; }
|
|
||||||
.match-table { margin-bottom: 18px; }
|
.match-table { margin-bottom: 18px; }
|
||||||
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
||||||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.ok { color: #27ae60; }
|
.ok { color: #27ae60; }
|
||||||
.fail { color: #e67e22; }
|
.fail { color: #e67e22; }
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 200px; margin-top: 8px; }
|
|
||||||
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
|
|
||||||
.result-subsection { padding: 12px 12px 4px; }
|
|
||||||
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
|
|
||||||
.files { font-size: 12px; color: #5e6878; }
|
.files { font-size: 12px; color: #5e6878; }
|
||||||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
|
||||||
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||||||
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||||||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||||
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-row { flex-direction: column; align-items: stretch; } }
|
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .schedule-row { flex-direction: column; align-items: stretch; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
|
||||||
@click="pushToPythonQueue">
|
@click="pushToPythonQueue">
|
||||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
{{ pushing ? '启动中...' : '启动任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">Python 回传字段按 id、asin、国家、价格、标题、图片链接数组提交;后端接收分片后按 10 条一批送检,结果区展示真实 LLM 批次进度。</p>
|
<p class="loading-msg">Python 回传字段按 id、asin、国家、价格、标题、图片链接数组提交;后端接收分片后按 10 条一批送检,结果区展示真实 LLM 批次进度。</p>
|
||||||
@@ -80,104 +80,32 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">货源查询</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="货源查询"
|
||||||
<div class="summary-card">
|
:cards="asinCards"
|
||||||
<span class="summary-label">运行中任务</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ dashboard.pendingTaskCount }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无当前任务"
|
||||||
<span class="summary-label">已结束任务</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
>
|
||||||
</div>
|
<template #item-extra="{ item }">
|
||||||
<div class="summary-card">
|
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||||
<span class="summary-label">成功任务</span>
|
</template>
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
<template #history-item-extra="{ item }">
|
||||||
</div>
|
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||||
<div class="summary-card">
|
</template>
|
||||||
<span class="summary-label">失败任务</span>
|
<template #item-actions="{ item }">
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
<template #history-item-actions="{ item }">
|
||||||
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
<div class="subsection-title">匹配任务</div>
|
@click="downloadResult(itemSource(item))">下载</button>
|
||||||
<div class="result-list-wrap">
|
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||||
<div class="result-list-header">
|
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
<span>当前任务</span>
|
</template>
|
||||||
</div>
|
</TaskCenterPanel>
|
||||||
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
|
||||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
|
||||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
|
||||||
<div v-if="showFileProgress(item)" class="file-progress">
|
|
||||||
<div class="file-progress-meta">
|
|
||||||
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
|
|
||||||
<span>总进度 {{ fileProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-progress-track">
|
|
||||||
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
|
|
||||||
{{ fileProgressCountLabel(item) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status running">{{ statusText(item) }}</span>
|
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>历史记录</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
|
|
||||||
<ul v-else class="task-list clean-result-list">
|
|
||||||
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
|
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
|
||||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
|
||||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
|
||||||
<div v-if="item.resultFilename" class="files">
|
|
||||||
{{ item.resultFilename || '下载结果' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
|
||||||
<div v-if="showFileProgress(item)" class="file-progress">
|
|
||||||
<div class="file-progress-meta">
|
|
||||||
<span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
|
|
||||||
<span>总进度 {{ fileProgressPercent(item) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-progress-track">
|
|
||||||
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
|
||||||
</div>
|
|
||||||
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
|
|
||||||
{{ fileProgressCountLabel(item) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
|
||||||
<button v-if="canDownload(item)" type="button" class="download"
|
|
||||||
@click="downloadResult(item)">下载</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)">删除</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,6 +118,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import {
|
import {
|
||||||
activateSimilarAsinTask,
|
activateSimilarAsinTask,
|
||||||
deleteSimilarAsinHistory,
|
deleteSimilarAsinHistory,
|
||||||
@@ -301,6 +231,48 @@ const historyOnlyItems = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const asinCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
|
||||||
|
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||||||
|
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||||||
|
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toAsinTaskView))
|
||||||
|
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() => historyOnlyItems.value.map(toAsinTaskView))
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): SimilarAsinHistoryItem {
|
||||||
|
return item.source as SimilarAsinHistoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAsinTaskView(item: SimilarAsinHistoryItem): TaskItemView {
|
||||||
|
const showProgress = showFileProgress(item)
|
||||||
|
const isRunning = normalizeTaskStatus(item) === 'RUNNING'
|
||||||
|
return {
|
||||||
|
key: `asin-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
|
||||||
|
title: item.sourceFilename || '货源查询',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: statusText(item),
|
||||||
|
statusClass: statusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
|
||||||
|
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
|
||||||
|
...(item.error ? [`错误:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
progress: showProgress
|
||||||
|
? {
|
||||||
|
percent: fileProgressPercent(item),
|
||||||
|
stage: displayFileProgressStage(item, isRunning ? '处理中' : '结果生成中'),
|
||||||
|
countLabel: hasFileProgressCount(item) ? fileProgressCountLabel(item) : undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
|
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
|
||||||
if (item.taskId == null) return item
|
if (item.taskId == null) return item
|
||||||
const liveItem = liveProgressItems.value[item.taskId]
|
const liveItem = liveProgressItems.value[item.taskId]
|
||||||
@@ -599,7 +571,7 @@ async function pushToPythonQueue() {
|
|||||||
clearParsedTask()
|
clearParsedTask()
|
||||||
await loadDashboard()
|
await loadDashboard()
|
||||||
await loadHistory({ force: true })
|
await loadHistory({ force: true })
|
||||||
ElMessage.success('已推送到 Python 队列')
|
ElMessage.success('已启动任务')
|
||||||
} finally {
|
} finally {
|
||||||
pushing.value = false
|
pushing.value = false
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1094,26 @@ onUnmounted(() => {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
timers.clearScope()
|
timers.clearScope()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1152,8 +1144,7 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title,
|
.section-title {
|
||||||
.subsection-title {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #a0acbe;
|
color: #a0acbe;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -1368,172 +1359,11 @@ onUnmounted(() => {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 20px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 180px;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
color: #c8d2e2;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tasks {
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, .18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, .18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.running {
|
|
||||||
background: rgba(52, 152, 219, .18);
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.pending {
|
|
||||||
background: rgba(149, 165, 166, .18);
|
|
||||||
color: #a0acbe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-hint {
|
.result-hint {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #e0b96d;
|
color: #e0b96d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-progress {
|
|
||||||
margin-top: 8px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-progress-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
color: #d8c278;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-progress-track {
|
|
||||||
margin-top: 5px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #333f55;
|
|
||||||
border: 1px solid #3b3b3b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(90deg, #4aa3ff, #f0c75e);
|
|
||||||
transition: width .25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-progress-count {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #858585;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
color: #c8d2e2;
|
color: #c8d2e2;
|
||||||
@@ -1557,9 +1387,5 @@ onUnmounted(() => {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid #2e3a52;
|
border-bottom: 1px solid #2e3a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -98,65 +98,37 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">拆分结果</div>
|
<TaskCenterPanel
|
||||||
<div class="task-list-wrap">
|
:on-batch-delete="batchDeleteHistory"
|
||||||
<div class="clean-result-summary">
|
title="拆分结果"
|
||||||
<div class="summary-card">
|
:cards="splitCards"
|
||||||
<span class="summary-label">已处理文件</span>
|
:current-items="currentTaskViews"
|
||||||
<strong>{{ splitSummary.total }}</strong>
|
:history-items="historyTaskViews"
|
||||||
</div>
|
current-title="当前任务"
|
||||||
<div class="summary-card">
|
current-empty-text="暂无当前任务,完成数据拆分后会在这里展示"
|
||||||
<span class="summary-label">成功结果</span>
|
history-empty-text="暂无历史记录"
|
||||||
<strong>{{ splitSummary.successCount }}</strong>
|
>
|
||||||
</div>
|
<template #history-item-actions="{ item }">
|
||||||
<div class="summary-card">
|
<template v-if="itemSource(item)">
|
||||||
<span class="summary-label">失败文件</span>
|
<button
|
||||||
<strong>{{ splitSummary.failedCount }}</strong>
|
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
class="download"
|
||||||
|
@click="downloadSplitResult(itemSource(item))"
|
||||||
<div class="result-list-wrap">
|
>
|
||||||
<div class="result-list-header">
|
下载压缩包
|
||||||
<span>拆分结果压缩包列表</span>
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
v-if="itemSource(item).resultId"
|
||||||
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
type="button"
|
||||||
暂无拆分结果,完成数据拆分后会在这里展示压缩包文件
|
class="btn-delete"
|
||||||
</div>
|
@click="deleteSplitHistoryRecord(itemSource(item).resultId!)"
|
||||||
|
>
|
||||||
<ul v-else class="task-list clean-result-list">
|
删除
|
||||||
<li v-for="item in splitResultItems"
|
</button>
|
||||||
:key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`"
|
</template>
|
||||||
class="task-item split-result-item">
|
</template>
|
||||||
<div class="left split-result-main">
|
</TaskCenterPanel>
|
||||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
|
||||||
<div v-if="item.outputFilename" class="files">压缩包文件:{{ item.outputFilename }}</div>
|
|
||||||
<div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div>
|
|
||||||
<div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件
|
|
||||||
</div>
|
|
||||||
<div v-if="item.entries && item.entries.length" class="files split-entry-list">
|
|
||||||
压缩包内容:{{ formatSplitEntries(item.entries) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-right split-result-actions">
|
|
||||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
|
||||||
{{ item.success ? '已完成' : '失败' }}
|
|
||||||
</span>
|
|
||||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
|
||||||
@click="downloadSplitResult(item)">
|
|
||||||
下载压缩包
|
|
||||||
</button>
|
|
||||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
|
||||||
@click="deleteSplitHistoryRecord(item.resultId)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -167,8 +139,10 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||||
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
|
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
||||||
@@ -185,15 +159,79 @@ const splitRowsPerFile = ref<number | null>(2000)
|
|||||||
const splitParts = ref<number | null>(5)
|
const splitParts = ref<number | null>(5)
|
||||||
const splitRunning = ref(false)
|
const splitRunning = ref(false)
|
||||||
const splitResultItems = ref<SplitResultItem[]>([])
|
const splitResultItems = ref<SplitResultItem[]>([])
|
||||||
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
|
||||||
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
|
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
|
||||||
|
|
||||||
function updateSplitSummaryFromItems(items: SplitResultItem[]) {
|
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||||
splitSummary.value = {
|
const splitCards = computed<TaskStatCard[]>(() => [
|
||||||
total: items.length,
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
successCount: items.filter((item) => item.success).length,
|
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||||||
failedCount: items.filter((item) => !item.success).length,
|
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||||||
items,
|
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
function isSplitTaskBusy(item: SplitResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
return status === 'RUNNING' || status === 'PENDING'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前任务:运行中/待执行项(拆分接口通常同步完成即出结果,正常为空) */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
splitResultItems.value.filter(isSplitTaskBusy).map(toSplitTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 历史任务:全部已结束的结果项 */
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
splitResultItems.value.filter((item) => !isSplitTaskBusy(item)).map(toSplitTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): SplitResultItem {
|
||||||
|
return item.source as SplitResultItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTaskStatusText(item: SplitResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'RUNNING') return '执行中'
|
||||||
|
if (status === 'PENDING') return '等待中'
|
||||||
|
return item.success ? '已完成' : '失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTaskStatusClass(item: SplitResultItem) {
|
||||||
|
const status = (item.taskStatus || '').toUpperCase()
|
||||||
|
if (status === 'RUNNING') return 'running'
|
||||||
|
if (status === 'PENDING') return 'pending'
|
||||||
|
return item.success ? 'success' : 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSplitTaskView(item: SplitResultItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: `split-${item.resultId ?? `${item.sourceFilename}-${item.rowCount ?? 0}`}`,
|
||||||
|
title: item.sourceFilename || '数据拆分',
|
||||||
|
taskId: item.taskId ?? '-',
|
||||||
|
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: splitTaskStatusText(item),
|
||||||
|
statusClass: splitTaskStatusClass(item),
|
||||||
|
extraLines: [
|
||||||
|
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
|
||||||
|
...(item.rowCount !== undefined ? [`压缩包共包含 ${item.rowCount} 条数据`] : []),
|
||||||
|
...(item.entryCount && item.entryCount > 0 ? [`压缩包内共 ${item.entryCount} 个拆分文件`] : []),
|
||||||
|
...(item.entries && item.entries.length ? [`压缩包内容:${formatSplitEntries(item.entries)}`] : []),
|
||||||
|
...(item.error ? [`错误信息:${item.error}`] : []),
|
||||||
|
],
|
||||||
|
source: item,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +352,6 @@ async function submitSplitRun() {
|
|||||||
? splitArchiveName.value
|
? splitArchiveName.value
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
splitSummary.value = result
|
|
||||||
splitResultItems.value = result.items || []
|
splitResultItems.value = result.items || []
|
||||||
await loadSplitHistory()
|
await loadSplitHistory()
|
||||||
if (result.total > 0 && result.successCount === 0) {
|
if (result.total > 0 && result.successCount === 0) {
|
||||||
@@ -341,7 +378,6 @@ async function loadSplitHistory() {
|
|||||||
try {
|
try {
|
||||||
const response = await getSplitHistory()
|
const response = await getSplitHistory()
|
||||||
splitResultItems.value = response.items || []
|
splitResultItems.value = response.items || []
|
||||||
updateSplitSummaryFromItems(splitResultItems.value)
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore history load errors
|
// ignore history load errors
|
||||||
}
|
}
|
||||||
@@ -387,6 +423,26 @@ async function downloadSplitResult(item: SplitResultItem) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadSplitHistory().catch(() => undefined)
|
loadSplitHistory().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteSplitHistoryRecord(itemSource(view).resultId!)
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -685,110 +741,12 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list-wrap {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-item {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
.files {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #5e6878;
|
color: #5e6878;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-entry-list {
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-all;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: rgba(46, 204, 113, 0.18);
|
|
||||||
color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: rgba(231, 76, 60, 0.18);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
.download {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -813,60 +771,6 @@ onMounted(() => {
|
|||||||
background: rgba(231, 76, 60, 0.22);
|
background: rgba(231, 76, 60, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tasks {
|
|
||||||
color: #5e6878;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 24px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-placeholder {
|
|
||||||
max-height: 112px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 220px));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #f5f8fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5e6878;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-wrap {
|
|
||||||
border: 1px solid #2e3a52;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1c2333;
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-list-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid #2e3a52;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c8d2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -882,24 +786,5 @@ onMounted(() => {
|
|||||||
.right-panel {
|
.right-panel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-right {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result-actions {
|
|
||||||
min-width: 0;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clean-result-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div v-if="$slots.logoExtra" class="logo-extra">
|
<div v-if="$slots.logoExtra" class="logo-extra">
|
||||||
<slot name="logoExtra" />
|
<slot name="logoExtra" />
|
||||||
</div>
|
</div>
|
||||||
<a v-if="showHomeLink" href="/home" class="btn-home">返回首页</a>
|
<router-link v-if="showHomeLink" to="/home" class="btn-home">返回首页</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航">
|
<nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航">
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
<div class="nav-section-title">{{ group.label }}</div>
|
<div class="nav-section-title">{{ group.label }}</div>
|
||||||
<div class="nav-section-items">
|
<div class="nav-section-items">
|
||||||
<template v-for="item in group.items" :key="item.key">
|
<template v-for="item in group.items" :key="item.key">
|
||||||
<a v-if="item.href" :href="item.href" class="nav-item" :class="{ active: active === item.key }">
|
<router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</a>
|
</router-link>
|
||||||
<span v-else class="nav-item disabled" :class="{ active: active === item.key }">
|
<span v-else class="nav-item disabled" :class="{ active: active === item.key }">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
@@ -94,30 +94,30 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
|||||||
columnKey: 'brand_front_tools',
|
columnKey: 'brand_front_tools',
|
||||||
label: '前端工具',
|
label: '前端工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
|
{ key: 'collect-data', label: '采集数据', href: '/collect-data' },
|
||||||
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/new_web_source/variant-collection.html' },
|
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/variant-collection' },
|
||||||
// { key: 'image-video', label: '视频', href: '/new_web_source/image-video.html' },
|
// { key: 'image-video', label: '视频', href: '/image-video' },
|
||||||
{ key: 'brand', label: '品牌检测', href: '/new_web_source/brand.html' },
|
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
{ key: 'appearance-patent', label: '外观专利检测', href: '/appearance-patent' },
|
||||||
{ key: 'similar-asin', label: '货源查询', href: '/new_web_source/similar-asin.html' },
|
{ key: 'similar-asin', label: '货源查询', href: '/similar-asin' },
|
||||||
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
|
{ key: 'dedupe', label: '数据去重', href: '/dedupe' },
|
||||||
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
|
{ key: 'split', label: '数据拆分', href: '/split' },
|
||||||
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
|
{ key: 'convert', label: '格式转换', href: '/convert' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
columnKey: 'brand_operation_tools',
|
columnKey: 'brand_operation_tools',
|
||||||
label: '运营工具',
|
label: '运营工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' },
|
{ key: 'publish', label: '上架', href: '/publish' },
|
||||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
{ key: 'delete-brand', label: '删除ASIN', href: '/delete-brand' },
|
||||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
{ key: 'product-risk', label: '商品风险解决', href: '/product-risk' },
|
||||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
{ key: 'shop-match', label: '定时匹配', href: '/shop-match' },
|
||||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] },
|
{ key: 'pricing', label: '跟价', href: '/price-track', aliases: ['price-track'] },
|
||||||
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
|
{ key: 'patrol-delete', label: '巡店删除', href: '/patrol-delete' },
|
||||||
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
|
{ key: 'query-asin', label: '查询ASIN', href: '/query-asin' },
|
||||||
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/new_web_source/shop-data-crawl.html', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
|
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/shop-data-crawl', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
|
||||||
{ key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' },
|
{ key: 'withdraw', label: '取款', href: '/withdraw' },
|
||||||
{ key: 'shop-status', label: '店铺状态查询' },
|
{ key: 'shop-status', label: '店铺状态查询' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,45 +86,9 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-toolbar">
|
||||||
<span>变体采集任务</span>
|
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
|
||||||
<div class="panel-actions">
|
<div v-if="totalCount" class="toolbar-pagination">
|
||||||
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-list-wrap">
|
|
||||||
<div v-if="!currentTasks.length" class="empty-tasks">暂无变体采集任务</div>
|
|
||||||
<ul v-else class="task-list">
|
|
||||||
<li v-for="item in currentTasks" :key="`vc-${item.task_id}`" class="task-item">
|
|
||||||
<div class="left">
|
|
||||||
<span class="id">任务 {{ item.task_id }}</span>
|
|
||||||
<div v-if="taskMeta(item).source" class="files">来源:{{ taskMeta(item).source }}</div>
|
|
||||||
<div class="files">采集国家:{{ countryText(item) || '-' }}</div>
|
|
||||||
<div class="files">更新时间:{{ item.update_time || formatDateTime(item.updated_at) }}</div>
|
|
||||||
<div v-if="latestMessage(item)" class="files">{{ latestMessage(item) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
|
|
||||||
<button type="button" class="act-btn" @click="refreshTask(item)">更新</button>
|
|
||||||
<button type="button" class="act-btn" @click="showDetail(item)">详情</button>
|
|
||||||
<button
|
|
||||||
v-if="hasResultFiles(item)"
|
|
||||||
type="button"
|
|
||||||
class="act-btn ok"
|
|
||||||
@click="downloadResult(item)"
|
|
||||||
>下载结果</button>
|
|
||||||
<button
|
|
||||||
v-if="hasSourceFile(item)"
|
|
||||||
type="button"
|
|
||||||
class="act-btn"
|
|
||||||
@click="downloadSource(item)"
|
|
||||||
>下载源文件</button>
|
|
||||||
<button type="button" class="act-btn warn" @click="reexportTask(item)">重新导出</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div v-if="currentTasks.length" class="pagination">
|
|
||||||
<span class="page-info">共 {{ totalCount }} 条 · 第 {{ currentPage }} / {{ totalPages }} 页</span>
|
<span class="page-info">共 {{ totalCount }} 条 · 第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||||
<div class="page-btns">
|
<div class="page-btns">
|
||||||
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
|
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
|
||||||
@@ -132,6 +96,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskCenterPanel
|
||||||
|
title="变体采集任务"
|
||||||
|
:cards="variantCards"
|
||||||
|
:current-items="currentTaskViews"
|
||||||
|
:history-items="historyTaskViews"
|
||||||
|
current-title="当前任务"
|
||||||
|
current-empty-text="暂无运行中的变体采集任务"
|
||||||
|
history-empty-text="暂无历史记录"
|
||||||
|
>
|
||||||
|
<template #item-actions="{ item }">
|
||||||
|
<template v-if="itemSource(item)">
|
||||||
|
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
|
||||||
|
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
|
||||||
|
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
|
||||||
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
|
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
|
||||||
|
@click="downloadSource(itemSource(item))">下载源文件</button>
|
||||||
|
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #history-item-actions="{ item }">
|
||||||
|
<template v-if="itemSource(item)">
|
||||||
|
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
|
||||||
|
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
|
||||||
|
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
|
||||||
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
|
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
|
||||||
|
@click="downloadSource(itemSource(item))">下载源文件</button>
|
||||||
|
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -166,6 +162,8 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import {
|
import {
|
||||||
getPywebviewApi,
|
getPywebviewApi,
|
||||||
type VariantTaskListItem,
|
type VariantTaskListItem,
|
||||||
@@ -214,6 +212,59 @@ const canSubmit = computed(() =>
|
|||||||
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
|
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||||
|
/** 当前任务:排队中(0) / 执行中(1) 的任务(当前分页内) */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
currentTasks.value.filter((item) => isBusy(item.status)).map(toVariantTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 历史任务:已完成(2) / 失败(3) 的任务(当前分页内) */
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
currentTasks.value.filter((item) => !isBusy(item.status)).map(toVariantTaskView),
|
||||||
|
)
|
||||||
|
|
||||||
|
const variantCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||||||
|
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||||||
|
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): VariantEntry {
|
||||||
|
return item.source as VariantEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantStatusClass(status?: number | string) {
|
||||||
|
const numeric = Number(status)
|
||||||
|
if (numeric === 1 || /running|processing/i.test(String(status))) return 'running'
|
||||||
|
if (numeric === 2 || /success|completed|done/i.test(String(status))) return 'success'
|
||||||
|
if (numeric === 3 || /failed|error/i.test(String(status))) return 'failed'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVariantTaskView(item: VariantEntry): TaskItemView {
|
||||||
|
const extraLines: string[] = []
|
||||||
|
const source = item.meta?.source || ''
|
||||||
|
if (source) extraLines.push(`来源:${source}`)
|
||||||
|
extraLines.push(`采集国家:${countryText(item) || '-'}`)
|
||||||
|
const updateText = item.update_time || formatDateTime(item.updated_at)
|
||||||
|
if (updateText) extraLines.push(`更新时间:${updateText}`)
|
||||||
|
extraLines.push(latestMessage(item))
|
||||||
|
return {
|
||||||
|
key: `vc-${item.task_id}`,
|
||||||
|
title: `任务 ${item.task_id}`,
|
||||||
|
taskId: item.task_id,
|
||||||
|
// 桥接列表未提供任务开始时间,开始时间占位为 '-'
|
||||||
|
startedAt: '-',
|
||||||
|
// 桥接仅提供最近更新时间,作为结束时间的近似展示
|
||||||
|
finishedAt: formatDateTime(item.update_time),
|
||||||
|
statusText: statusText(item.status),
|
||||||
|
statusClass: variantStatusClass(item.status),
|
||||||
|
extraLines,
|
||||||
|
source: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let autoTimer: number | null = null
|
let autoTimer: number | null = null
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
|
||||||
@@ -651,26 +702,12 @@ onBeforeUnmount(() => {
|
|||||||
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||||
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.loading-msg { margin-top: 10px; }
|
.loading-msg { margin-top: 10px; }
|
||||||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
|
.panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
|
||||||
.panel-actions { display: flex; gap: 8px; }
|
|
||||||
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
.toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
|
||||||
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
|
|
||||||
.task-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
|
||||||
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
|
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
|
||||||
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
|
|
||||||
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
|
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
|
||||||
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
|
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
|
||||||
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
|
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
|
||||||
.pagination { display: flex; justify-content: space-between; align-items: center; padding: 10px 0 4px; }
|
|
||||||
.page-info { color: #5e6878; font-size: 12px; }
|
.page-info { color: #5e6878; font-size: 12px; }
|
||||||
.page-btns { display: flex; gap: 8px; }
|
.page-btns { display: flex; gap: 8px; }
|
||||||
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
|
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="section-title">店铺输入</div>
|
<div class="section-title">店铺输入</div>
|
||||||
<div class="input-zone">
|
<div class="input-zone">
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。每次推送会把本次选中的所有店铺合并为一条取款任务,任务之间串行执行。
|
左侧负责录入店铺并加入备选区,确认命中后启动任务。每次推送会把本次选中的所有店铺合并为一条取款任务,任务之间串行执行。
|
||||||
</div>
|
</div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
||||||
@click="pushToPythonQueue"
|
@click="pushToPythonQueue"
|
||||||
>
|
>
|
||||||
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
|
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,223 +106,114 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-header">匹配与任务</div>
|
<div class="match-zone">
|
||||||
<div class="task-list-wrap">
|
<div class="match-zone-header">
|
||||||
<div class="clean-result-summary">
|
<span>匹配结果</span>
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">备选店铺</span>
|
|
||||||
<strong>{{ dashboard.candidateCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">已处理任务</span>
|
|
||||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">成功任务</span>
|
|
||||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<span class="summary-label">失败任务</span>
|
|
||||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!matchedItems.length" class="match-zone-empty">
|
||||||
<div class="subsection-title">匹配结果</div>
|
|
||||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">
|
|
||||||
完成“匹配店铺”后,这里会展示匹配结果。
|
完成“匹配店铺”后,这里会展示匹配结果。
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<div v-else class="match-zone-scroll">
|
||||||
v-else
|
<el-table
|
||||||
:data="matchedItems"
|
:data="matchedItems"
|
||||||
:row-key="rowKeyForMatch"
|
:row-key="rowKeyForMatch"
|
||||||
:highlight-current-row="false"
|
:highlight-current-row="false"
|
||||||
class="result-table match-table"
|
class="result-table match-table"
|
||||||
>
|
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :class="row.matched ? 'ok' : 'fail'">
|
|
||||||
{{ row.matched ? "是" : "否" }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="shopId"
|
|
||||||
label="店铺 ID"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="platform"
|
|
||||||
label="平台"
|
|
||||||
width="88"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="companyName"
|
|
||||||
label="公司"
|
|
||||||
min-width="120"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchStatus(row.matchStatus) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatMatchRemark(row) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link-danger"
|
|
||||||
@click="removeMatchedRow(row)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="result-list-wrap">
|
|
||||||
<div class="result-list-header">
|
|
||||||
<span>任务记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!currentSectionItems.length && !historySectionItems.length"
|
|
||||||
class="empty-tasks"
|
|
||||||
>
|
>
|
||||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||||
</div>
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
<template v-else>
|
<span :class="row.matched ? 'ok' : 'fail'">
|
||||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
{{ row.matched ? "是" : "否" }}
|
||||||
<div class="result-subsection-title">当前任务</div>
|
</span>
|
||||||
<ul class="task-list">
|
</template>
|
||||||
<li
|
</el-table-column>
|
||||||
v-for="item in currentSectionItems"
|
<el-table-column
|
||||||
:key="`current-${item.taskId || item.resultId}`"
|
prop="shopId"
|
||||||
class="task-item"
|
label="店铺 ID"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="platform"
|
||||||
|
label="平台"
|
||||||
|
width="88"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="companyName"
|
||||||
|
label="公司"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchStatus(row.matchStatus) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMatchRemark(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-danger"
|
||||||
|
@click="removeMatchedRow(row)"
|
||||||
>
|
>
|
||||||
<div class="left split-result-main">
|
删除
|
||||||
<span class="id" :title="item.shopName || ''">
|
</button>
|
||||||
取款任务 {{ item.taskId ?? "-" }}
|
</template>
|
||||||
</span>
|
</el-table-column>
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
</el-table>
|
||||||
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
|
|
||||||
<div class="files">
|
|
||||||
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
取款结果: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.shops.length" class="shop-detail-list">
|
|
||||||
<div
|
|
||||||
v-for="shop in item.shops"
|
|
||||||
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
|
|
||||||
class="files shop-detail-line"
|
|
||||||
>
|
|
||||||
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">
|
|
||||||
错误: {{ item.error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="historySectionItems.length" class="result-subsection">
|
|
||||||
<div class="result-subsection-title">历史任务</div>
|
|
||||||
<ul class="task-list">
|
|
||||||
<li
|
|
||||||
v-for="item in historySectionItems"
|
|
||||||
:key="`history-${item.taskId || item.resultId}`"
|
|
||||||
class="task-item"
|
|
||||||
>
|
|
||||||
<div class="left split-result-main">
|
|
||||||
<span class="id" :title="item.shopName || ''">
|
|
||||||
取款任务 {{ item.taskId ?? "-" }}
|
|
||||||
</span>
|
|
||||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
|
||||||
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
|
|
||||||
<div class="files">
|
|
||||||
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.finishedAt" class="files">
|
|
||||||
完成时间: {{ formatDateTime(item.finishedAt) }}
|
|
||||||
</div>
|
|
||||||
<div class="files">
|
|
||||||
取款结果: {{ formatTemplateSummary(item) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="item.shops.length" class="shop-detail-list">
|
|
||||||
<div
|
|
||||||
v-for="shop in item.shops"
|
|
||||||
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
|
|
||||||
class="files shop-detail-line"
|
|
||||||
>
|
|
||||||
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.error" class="files">
|
|
||||||
错误: {{ item.error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-right">
|
|
||||||
<span
|
|
||||||
class="status"
|
|
||||||
:class="statusClass(item.taskStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(item.taskStatus) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="canDownload(item)"
|
|
||||||
type="button"
|
|
||||||
class="archive"
|
|
||||||
@click="downloadResult(item)"
|
|
||||||
>
|
|
||||||
下载结果
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="deleteTaskRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TaskCenterPanel
|
||||||
|
:on-batch-delete="batchDeleteHistory"
|
||||||
|
title="匹配与任务"
|
||||||
|
:cards="withdrawCards"
|
||||||
|
:current-items="currentTaskViews"
|
||||||
|
:history-items="historyTaskViews"
|
||||||
|
current-title="当前任务"
|
||||||
|
current-empty-text="暂无当前任务,启动任务后运行中的任务会显示在这里"
|
||||||
|
history-empty-text="暂无历史任务"
|
||||||
|
>
|
||||||
|
<template #item-extra="{ item }">
|
||||||
|
<div class="files">取款结果:{{ formatTemplateSummary(itemSource(item)) }}</div>
|
||||||
|
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
|
||||||
|
<div
|
||||||
|
v-for="shop in itemSource(item).shops"
|
||||||
|
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
|
||||||
|
class="files shop-detail-line"
|
||||||
|
>
|
||||||
|
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #history-item-extra="{ item }">
|
||||||
|
<div class="files">取款结果:{{ formatTemplateSummary(itemSource(item)) }}</div>
|
||||||
|
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
|
||||||
|
<div
|
||||||
|
v-for="shop in itemSource(item).shops"
|
||||||
|
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
|
||||||
|
class="files shop-detail-line"
|
||||||
|
>
|
||||||
|
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #item-actions="{ item }">
|
||||||
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
|
</template>
|
||||||
|
<template #history-item-actions="{ item }">
|
||||||
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
|
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||||
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
|
</template>
|
||||||
|
</TaskCenterPanel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AmazonToolPageShell>
|
</AmazonToolPageShell>
|
||||||
@@ -333,6 +224,8 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||||
|
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||||||
|
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||||||
import {
|
import {
|
||||||
addWithdrawCandidate,
|
addWithdrawCandidate,
|
||||||
createWithdrawTask,
|
createWithdrawTask,
|
||||||
@@ -419,6 +312,43 @@ const hasQueuedTaskWork = computed(
|
|||||||
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
|
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
|
||||||
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
|
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
|
||||||
|
|
||||||
|
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||||
|
const withdrawCards = computed<TaskStatCard[]>(() => [
|
||||||
|
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||||||
|
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||||||
|
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||||||
|
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 当前任务:按任务分组后非终态的任务卡 */
|
||||||
|
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
currentSectionItems.value.map(toWithdrawTaskView),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 历史任务:按任务分组后已终态的任务卡 */
|
||||||
|
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||||||
|
historySectionItems.value.map(toWithdrawTaskView),
|
||||||
|
);
|
||||||
|
|
||||||
|
function itemSource(item: TaskItemView): WithdrawTaskGroupItem {
|
||||||
|
return item.source as WithdrawTaskGroupItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一任务一卡:标题优先店铺名(多店以“、”连接),其次结果文件名 */
|
||||||
|
function toWithdrawTaskView(item: WithdrawTaskGroupItem): TaskItemView {
|
||||||
|
return {
|
||||||
|
key: `withdraw-${item.taskId ?? item.resultId ?? item.shopName}`,
|
||||||
|
title: item.shopName || item.outputFilename || `取款任务 ${item.taskId ?? "-"}`,
|
||||||
|
taskId: item.taskId ?? "-",
|
||||||
|
startedAt: formatDateTime(taskStartTime(item.taskId) || item.createdAt),
|
||||||
|
finishedAt: formatDateTime(item.finishedAt),
|
||||||
|
statusText: statusText(item.taskStatus),
|
||||||
|
statusClass: statusClass(item.taskStatus),
|
||||||
|
extraLines: item.error ? [`错误:${item.error}`] : [],
|
||||||
|
source: item,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function uidForStorage() {
|
function uidForStorage() {
|
||||||
return typeof window !== "undefined"
|
return typeof window !== "undefined"
|
||||||
? window.localStorage.getItem("uid") || "0"
|
? window.localStorage.getItem("uid") || "0"
|
||||||
@@ -604,7 +534,7 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
|
|||||||
const message = (row.matchMessage || "").trim();
|
const message = (row.matchMessage || "").trim();
|
||||||
if (message) return message;
|
if (message) return message;
|
||||||
if (row.matched && row.matchStatus === "MATCHED") {
|
if (row.matched && row.matchStatus === "MATCHED") {
|
||||||
return "索引已命中,可推送到 Python 队列";
|
return "索引已命中,可启动任务";
|
||||||
}
|
}
|
||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已命中索引,请结合状态列确认";
|
return "已命中索引,请结合状态列确认";
|
||||||
@@ -1313,6 +1243,26 @@ onUnmounted(() => {
|
|||||||
clearSleepTimers();
|
clearSleepTimers();
|
||||||
timers.clearScope();
|
timers.clearScope();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||||
|
*/
|
||||||
|
async function batchDeleteHistory(views: TaskItemView[]) {
|
||||||
|
if (!views.length) return
|
||||||
|
let failed = 0
|
||||||
|
for (const view of views) {
|
||||||
|
try {
|
||||||
|
await deleteTaskRecord(itemSource(view))
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1347,43 +1297,74 @@ onUnmounted(() => {
|
|||||||
.queue-debug-title { margin-bottom: 8px; }
|
.queue-debug-title { margin-bottom: 8px; }
|
||||||
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||||||
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
|
.match-zone {
|
||||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
flex: 0 0 auto;
|
||||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
margin: 16px 20px 0;
|
||||||
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
|
border: 1px solid #2e3a52;
|
||||||
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
|
border-radius: 10px;
|
||||||
.summary-label { font-size: 12px; color: #5e6878; }
|
background: #1c2333;
|
||||||
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
|
overflow: hidden;
|
||||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
}
|
||||||
.empty-tasks.narrow { padding: 12px 8px; }
|
|
||||||
.match-table { margin-bottom: 18px; }
|
.match-zone-header {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #2e3a52;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #a0acbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-zone-empty {
|
||||||
|
padding: 18px 16px;
|
||||||
|
color: #5e6878;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-zone-scroll {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
||||||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
|
||||||
.ok { color: #27ae60; }
|
.ok { color: #27ae60; }
|
||||||
.fail { color: #e67e22; }
|
.fail { color: #e67e22; }
|
||||||
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
|
|
||||||
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
|
|
||||||
.result-subsection { padding: 12px 12px 4px; }
|
|
||||||
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
|
|
||||||
.task-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
|
||||||
.left { flex: 1; min-width: 0; }
|
|
||||||
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
|
|
||||||
.files { font-size: 12px; color: #5e6878; }
|
.files { font-size: 12px; color: #5e6878; }
|
||||||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
.shop-detail-list {
|
||||||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
display: flex;
|
||||||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
flex-direction: column;
|
||||||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
gap: 2px;
|
||||||
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
margin-top: 4px;
|
||||||
.archive:hover { background: rgba(52, 152, 219, 0.28); }
|
}
|
||||||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
|
||||||
|
.download {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(52, 152, 219, 0.18);
|
||||||
|
color: #69b6ff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(231, 76, 60, 0.12);
|
||||||
|
color: #ff8f8f;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-content { flex-direction: column; height: auto; }
|
.main-content { flex-direction: column; height: auto; }
|
||||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -26,49 +26,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="username-text">{{ username || '未登录' }}</span>
|
<span class="username-text">{{ username || '未登录' }}</span>
|
||||||
<a :href="logoutHref" class="logout-link">退出</a>
|
<router-link :to="logoutHref" class="logout-link">退出</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="entrances">
|
<div class="entrances">
|
||||||
<a
|
<router-link
|
||||||
v-if="canShow.amazon"
|
v-if="canShow.amazon"
|
||||||
:href="amazonHref || undefined"
|
:to="amazonHref"
|
||||||
class="entrance-btn"
|
class="entrance-btn"
|
||||||
:class="{ disabled: !amazonHref }"
|
:class="{ disabled: !amazonHref }"
|
||||||
data-column-key="brand"
|
data-column-key="brand"
|
||||||
>亚马逊</a>
|
>亚马逊</router-link>
|
||||||
<a
|
|
||||||
v-else-if="showAmazonDisabled"
|
|
||||||
class="entrance-btn disabled"
|
|
||||||
data-column-key="brand"
|
|
||||||
>亚马逊</a>
|
|
||||||
|
|
||||||
<a
|
<router-link
|
||||||
v-if="canShow.video"
|
v-if="canShow.video"
|
||||||
:href="videoHref || undefined"
|
:to="videoHref"
|
||||||
class="entrance-btn wb"
|
class="entrance-btn wb"
|
||||||
:class="{ disabled: !videoHref }"
|
:class="{ disabled: !videoHref }"
|
||||||
data-column-key="wb"
|
data-column-key="wb"
|
||||||
>视频</a>
|
>视频</router-link>
|
||||||
<a
|
|
||||||
v-else-if="showVideoDisabled"
|
|
||||||
class="entrance-btn wb disabled"
|
|
||||||
data-column-key="wb"
|
|
||||||
>视频</a>
|
|
||||||
|
|
||||||
<a
|
<router-link
|
||||||
v-if="canShow.image"
|
v-if="canShow.image"
|
||||||
:href="imageHref || undefined"
|
:to="imageHref"
|
||||||
class="entrance-btn image"
|
class="entrance-btn image"
|
||||||
:class="{ disabled: !imageHref }"
|
:class="{ disabled: !imageHref }"
|
||||||
data-column-key="image"
|
data-column-key="image"
|
||||||
>图片</a>
|
>图片</router-link>
|
||||||
<a
|
|
||||||
v-else-if="showImageDisabled"
|
|
||||||
class="entrance-btn image disabled"
|
|
||||||
data-column-key="image"
|
|
||||||
>图片</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
|
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
|
||||||
@@ -77,8 +62,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { requestGetJson } from '@/shared/api/http'
|
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||||
import { buildJavaUrl } from '@/shared/api/url'
|
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
|
||||||
import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview'
|
||||||
import { resolvePageHref } from '@/shared/page-prefix'
|
import { resolvePageHref } from '@/shared/page-prefix'
|
||||||
|
|
||||||
@@ -90,7 +75,7 @@ const updateReady = ref(false)
|
|||||||
const updateFileUrl = ref('')
|
const updateFileUrl = ref('')
|
||||||
const toastText = ref('')
|
const toastText = ref('')
|
||||||
|
|
||||||
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(login.html?logout=1)
|
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(/login?logout=1)
|
||||||
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
||||||
|
|
||||||
type PermissionState = { amazon: boolean; video: boolean; image: boolean }
|
type PermissionState = { amazon: boolean; video: boolean; image: boolean }
|
||||||
@@ -105,10 +90,6 @@ const imageHref = computed(() => resolvePageHref('/new_web_source/image.html'))
|
|||||||
const canShow = computed(() =>
|
const canShow = computed(() =>
|
||||||
permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value,
|
permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value,
|
||||||
)
|
)
|
||||||
// dev 环境没有 /image 等旧路径资源时,给出“仅桌面”灰卡而不是消失
|
|
||||||
const showAmazonDisabled = computed(() => !permissionUnavailable.value && permissionState.value.amazon && !amazonHref.value)
|
|
||||||
const showVideoDisabled = computed(() => !permissionUnavailable.value && permissionState.value.video && !videoHref.value)
|
|
||||||
const showImageDisabled = computed(() => !permissionUnavailable.value && permissionState.value.image && !imageHref.value)
|
|
||||||
|
|
||||||
function getUid() {
|
function getUid() {
|
||||||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||||||
@@ -116,7 +97,7 @@ function getUid() {
|
|||||||
return Number.isFinite(numeric) && numeric > 0 ? String(numeric) : ''
|
return Number.isFinite(numeric) && numeric > 0 ? String(numeric) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePermissionState(items: Array<Record<string, unknown>>): PermissionState {
|
function computePermissionState(items: PermissionMenuItem[]): PermissionState {
|
||||||
const allowedKeys: string[] = []
|
const allowedKeys: string[] = []
|
||||||
const allowedColumnKeys: string[] = []
|
const allowedColumnKeys: string[] = []
|
||||||
const allowedRootKeys: string[] = []
|
const allowedRootKeys: string[] = []
|
||||||
@@ -126,7 +107,8 @@ function computePermissionState(items: Array<Record<string, unknown>>): Permissi
|
|||||||
if (key && list.indexOf(key) < 0) list.push(key)
|
if (key && list.indexOf(key) < 0) list.push(key)
|
||||||
}
|
}
|
||||||
;(items || []).forEach((item) => {
|
;(items || []).forEach((item) => {
|
||||||
push(allowedColumnKeys, item.columnKey || item.column_key)
|
push(allowedColumnKeys, item.columnKey)
|
||||||
|
push(allowedColumnKeys, item.column_key)
|
||||||
const root = item.rootColumnKey || item.root_column_key
|
const root = item.rootColumnKey || item.root_column_key
|
||||||
if (root) {
|
if (root) {
|
||||||
hasRootMetadata = true
|
hasRootMetadata = true
|
||||||
@@ -154,19 +136,10 @@ async function loadPermissions() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await requestGetJson<{ success: boolean; data?: Array<Record<string, unknown>> }>(
|
// 经 shared/api/permission 层拉取原始权限菜单项(含本地缓存写入;接口失败走 catch)
|
||||||
buildJavaUrl(`/api/admin/permission-users/${currentUid}/column-permissions?menuType=app`),
|
const items = await getCurrentUserAppColumnRaw()
|
||||||
)
|
permissionState.value = computePermissionState(items)
|
||||||
if (resp?.success && Array.isArray(resp.data)) {
|
permissionUnavailable.value = false
|
||||||
permissionState.value = computePermissionState(resp.data)
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(`app_column_permissions:${currentUid}`, JSON.stringify(resp.data))
|
|
||||||
} catch {
|
|
||||||
/* 忽略 */
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
permissionUnavailable.value = true
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
permissionUnavailable.value = true
|
permissionUnavailable.value = true
|
||||||
}
|
}
|
||||||
@@ -177,21 +150,10 @@ async function loadCurrentUser() {
|
|||||||
try {
|
try {
|
||||||
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
|
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
|
||||||
if (localName) username.value = localName
|
if (localName) username.value = localName
|
||||||
const token = typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || ''
|
// 经 shared/auth 层恢复登录快照(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
|
||||||
const resp = await window.fetch('/newApi/check_login', {
|
const restored = await restoreLoginUser()
|
||||||
credentials: 'include',
|
if (restored?.username) {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
username.value = restored.username
|
||||||
})
|
|
||||||
const body = await resp.json().catch(() => ({}))
|
|
||||||
if (body?.success && body.data) {
|
|
||||||
if (body.data.username) username.value = body.data.username
|
|
||||||
if (body.data.userId) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('uid', String(body.data.userId))
|
|
||||||
} catch {
|
|
||||||
/* 忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* 保留本地用户名 */
|
/* 保留本地用户名 */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="currentView === 'menu'" class="image-video-page">
|
<div v-if="currentView === 'menu'" class="image-video-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<span class="header-title">数富AI</span>
|
<span class="header-title">数富AI</span>
|
||||||
<a class="back-link" :href="goHome()">返回首页</a>
|
<router-link class="back-link" :to="goHome()">返回首页</router-link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口">
|
<main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口">
|
||||||
@@ -21,12 +21,9 @@
|
|||||||
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
|
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||||
混剪
|
混剪
|
||||||
</button>
|
</button>
|
||||||
<a v-if="hasMenuPermission('image') && isDesktop" class="entrance-btn" href="/new_web_source/image.html">
|
<router-link v-if="hasMenuPermission('image')" class="entrance-btn" to="/image">
|
||||||
图片
|
图片
|
||||||
</a>
|
</router-link>
|
||||||
<button v-else-if="hasMenuPermission('image')" type="button" class="entrance-btn" @click="showDesktopOnly('图片工作台')">
|
|
||||||
图片
|
|
||||||
</button>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||||
@@ -56,7 +53,7 @@
|
|||||||
:show-actions="false"
|
:show-actions="false"
|
||||||
>
|
>
|
||||||
<template #logoExtra>
|
<template #logoExtra>
|
||||||
<button type="button" class="delivery-page__top-return" @click="currentView = 'menu'">
|
<button type="button" class="delivery-page__top-return" @click="goBackToMenu">
|
||||||
返回二级菜单
|
返回二级菜单
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -71,30 +68,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import PageShell from '@/components/layout/PageShell.vue'
|
import PageShell from '@/components/layout/PageShell.vue'
|
||||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||||
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import { isDesktopClientPage } from '@/shared/page-prefix'
|
|
||||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||||
|
|
||||||
type ViewMode = 'menu' | 'delivery'
|
type ViewMode = 'menu' | 'delivery'
|
||||||
|
|
||||||
const isDesktop = isDesktopClientPage()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
// 桌面端首页是 Flask /home(重定向到 Vue home);dev 下是 MPA 的 /home.html
|
// 桌面端与 Web 统一为 SPA 首页路径
|
||||||
return isDesktop ? '/home' : '/home.html'
|
return '/home'
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDesktopOnly(name: string) {
|
/** 视图由路由 query 驱动:?view=delivery 为带货视频工作台,否则二级菜单页 */
|
||||||
showStatus(`${name}为桌面客户端功能,请在本地客户端中打开`, 'error')
|
const currentView = ref<ViewMode>(route.query.view === 'delivery' ? 'delivery' : 'menu')
|
||||||
}
|
|
||||||
|
|
||||||
const currentView = ref<ViewMode>('menu')
|
|
||||||
const allowedColumnKeys = ref(new Set<string>())
|
const allowedColumnKeys = ref(new Set<string>())
|
||||||
const permissionsLoading = ref(true)
|
const permissionsLoading = ref(true)
|
||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
@@ -139,10 +134,22 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.view,
|
||||||
|
(view) => {
|
||||||
|
currentView.value = view === 'delivery' ? 'delivery' : 'menu'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function openDeliveryWorkspace() {
|
function openDeliveryWorkspace() {
|
||||||
currentView.value = 'delivery'
|
router.push({ path: '/image-video', query: { view: 'delivery' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBackToMenu() {
|
||||||
|
router.push({ path: '/image-video' })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
function formatBytes(value: number) {
|
||||||
const size = Number(value || 0)
|
const size = Number(value || 0)
|
||||||
if (!Number.isFinite(size) || size <= 0) return '未知大小'
|
if (!Number.isFinite(size) || size <= 0) return '未知大小'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, reactive, ref } from 'vue'
|
import { computed, nextTick, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
import { STORAGE_API_KEY, STORAGE_AUTO_SAVE_PATH } from './workbench-shared'
|
import { STORAGE_API_KEY, STORAGE_AUTO_SAVE_PATH } from './workbench-shared'
|
||||||
@@ -12,6 +13,8 @@ import ClothingDetailPanel from './components/panels/ClothingDetailPanel.vue'
|
|||||||
import ExtremeDetailPanel from './components/panels/ExtremeDetailPanel.vue'
|
import ExtremeDetailPanel from './components/panels/ExtremeDetailPanel.vue'
|
||||||
import CloneDetailPanel from './components/panels/CloneDetailPanel.vue'
|
import CloneDetailPanel from './components/panels/CloneDetailPanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
interface BuildResult {
|
interface BuildResult {
|
||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
error?: string
|
error?: string
|
||||||
@@ -446,7 +449,7 @@ async function callGenerate(rawParams: Record<string, unknown>) {
|
|||||||
signal: currentAbortController.signal,
|
signal: currentAbortController.signal,
|
||||||
})
|
})
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
window.location.href = '/login'
|
router.replace('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result = await resp.json()
|
result = await resp.json()
|
||||||
@@ -595,7 +598,7 @@ async function loadHistoryPage(append: boolean) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { credentials: 'same-origin' })
|
const resp = await fetch(url, { credentials: 'same-origin' })
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
window.location.href = '/login'
|
router.replace('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
@@ -1182,7 +1185,7 @@ loadHistoryPage(false)
|
|||||||
<div class="logo-area">
|
<div class="logo-area">
|
||||||
<img class="logo" :src="logoUrl" alt="logo" />
|
<img class="logo" :src="logoUrl" alt="logo" />
|
||||||
<span class="app-name">数富AI</span>
|
<span class="app-name">数富AI</span>
|
||||||
<a href="/home" class="btn-home" title="返回首页">← 返回首页</a>
|
<router-link to="/home" class="btn-home" title="返回首页">← 返回首页</router-link>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-tabs">
|
<nav class="nav-tabs">
|
||||||
<span class="nav-tab-group">
|
<span class="nav-tab-group">
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
pasteClipboardImages,
|
||||||
|
readFilesAsDataUrls,
|
||||||
|
} from '../image-utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
max: number
|
||||||
|
title?: string
|
||||||
|
hint?: string
|
||||||
|
zoneHint?: string
|
||||||
|
zonePrefix?: string
|
||||||
|
/** none=无粘贴 | row=标题行右侧粘贴按钮 | below=标题下独立粘贴按钮 */
|
||||||
|
pastePos?: 'none' | 'row' | 'below'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
hint: '',
|
||||||
|
zoneHint: '支持多选/拖拽上传',
|
||||||
|
zonePrefix: '点击上传图片',
|
||||||
|
pastePos: 'none',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string[]>({ required: true })
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
function choose() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
if (input.files?.length) void addFiles(input.files)
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFiles(files: Blob[] | FileList) {
|
||||||
|
const allowed = Array.from(files).filter((f) => ALLOWED_IMAGE_TYPES.includes(f.type))
|
||||||
|
if (!allowed.length) return
|
||||||
|
const remaining = Math.max(0, props.max - model.value.length)
|
||||||
|
if (!remaining) return
|
||||||
|
const urls = await readFilesAsDataUrls(allowed.slice(0, remaining))
|
||||||
|
model.value = model.value.concat(urls).slice(0, props.max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(i: number) {
|
||||||
|
const next = model.value.slice()
|
||||||
|
next.splice(i, 1)
|
||||||
|
model.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteImages() {
|
||||||
|
const images = await pasteClipboardImages()
|
||||||
|
if (images.length) void addFiles(images)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragging.value = false
|
||||||
|
if (e.dataTransfer?.files?.length) void addFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="option-group">
|
||||||
|
<div v-if="title" class="section-title-row">
|
||||||
|
<span class="section-title">{{ title }}</span>
|
||||||
|
<button
|
||||||
|
v-if="pastePos === 'row'"
|
||||||
|
type="button"
|
||||||
|
class="btn-paste-alone btn-paste-right"
|
||||||
|
@click="pasteImages"
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
粘贴图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="pastePos === 'below'"
|
||||||
|
type="button"
|
||||||
|
class="btn-paste-alone"
|
||||||
|
@click="pasteImages"
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
粘贴图片
|
||||||
|
</button>
|
||||||
|
<div v-if="hint" class="hint-text">{{ hint }}</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.bmp"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="model.length < max"
|
||||||
|
class="upload-zone"
|
||||||
|
:class="{ 'drag-over': dragging }"
|
||||||
|
@click="choose"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<div class="upload-zone-icon">↑</div>
|
||||||
|
<div class="upload-zone-text">{{ zonePrefix }} ({{ model.length }}/{{ max }})</div>
|
||||||
|
<div v-if="zoneHint" class="upload-zone-hint">{{ zoneHint }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="model.length > 0" class="upload-preview product-detail-preview">
|
||||||
|
<div v-for="(u, i) in model" :key="i" class="upload-preview-item">
|
||||||
|
<img :src="u" :alt="'预览' + (i + 1)" />
|
||||||
|
<button class="remove-btn" type="button" @click.stop="removeAt(i)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ImageListField from './ImageListField.vue'
|
||||||
|
import { modelImages } from '../workbench-shared'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ImageListField
|
||||||
|
v-model="modelImages"
|
||||||
|
:max="5"
|
||||||
|
title="多模特图上传(最多5张)"
|
||||||
|
hint="支持JPG PNG BMP JPEG"
|
||||||
|
:paste-pos="'none'"
|
||||||
|
:zone-hint="''"
|
||||||
|
zone-prefix="点击上传"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ALLOWED_IMAGE_TYPES, readFilesAsDataUrls } from '../image-utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
hint?: string
|
||||||
|
zoneText?: string
|
||||||
|
accept?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
hint: '',
|
||||||
|
zoneText: '点击上传',
|
||||||
|
accept: '.jpg,.jpeg,.png,.bmp',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string | null>({ default: null })
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
function choose() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) void setFile(file)
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFile(file: File) {
|
||||||
|
if (props.accept.startsWith('video')) {
|
||||||
|
if (!file.type.startsWith('video/')) return
|
||||||
|
} else if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const urls = await readFilesAsDataUrls([file])
|
||||||
|
model.value = urls[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFile() {
|
||||||
|
model.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragging.value = false
|
||||||
|
const file = e.dataTransfer?.files?.[0]
|
||||||
|
if (file) void setFile(file)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="option-group">
|
||||||
|
<div v-if="title" class="section-title">{{ title }}</div>
|
||||||
|
<div v-if="hint" class="hint-text">{{ hint }}</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
hidden
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!model"
|
||||||
|
class="upload-zone"
|
||||||
|
:class="{ 'drag-over': dragging }"
|
||||||
|
@click="choose"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<div class="upload-zone-icon">↑</div>
|
||||||
|
<div class="upload-zone-text">{{ zoneText }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-preview product-detail-preview">
|
||||||
|
<div v-if="accept.startsWith('video')" class="upload-preview-item" style="grid-column: 1/-1;">
|
||||||
|
<span style="color: #888;">已选视频</span>
|
||||||
|
<button class="remove-btn" type="button" @click="clearFile">×</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-preview-item">
|
||||||
|
<img :src="model" alt="预览" />
|
||||||
|
<button class="remove-btn" type="button" @click="clearFile">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ count: number }>()
|
||||||
|
|
||||||
|
const model = defineModel<string[]>({ default: [] })
|
||||||
|
|
||||||
|
function resize(n: number) {
|
||||||
|
const safeN = Math.max(0, Math.min(50, Math.max(1, Number(n) || 1)))
|
||||||
|
const vals = Array.isArray(model.value) ? model.value : []
|
||||||
|
const next: string[] = []
|
||||||
|
for (let i = 0; i < safeN; i++) next.push(i < vals.length ? String(vals[i] ?? '') : '')
|
||||||
|
model.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.count,
|
||||||
|
(n) => resize(n),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specify-screen-list">
|
||||||
|
<div v-for="(_, i) in model" :key="i" class="specify-screen-item">
|
||||||
|
<label>第{{ i + 1 }}屏文案 (可选)</label>
|
||||||
|
<input
|
||||||
|
v-model="model[i]"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
:data-screen-index="i + 1"
|
||||||
|
placeholder="如:高颜值外观,一眼心动"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { RATIO_OPTIONS_AUTO, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'CloneDetailPanel' })
|
||||||
|
|
||||||
|
const productName = ref('')
|
||||||
|
const features = ref('')
|
||||||
|
const cloneMode = ref<'domestic' | 'amazon' | 'specify'>('domestic')
|
||||||
|
const ratio = ref('auto')
|
||||||
|
const res = ref('2k')
|
||||||
|
const language = ref('')
|
||||||
|
const procImages = ref<string[]>([])
|
||||||
|
const refImages = ref<string[]>([])
|
||||||
|
const specify = ref<string[]>([])
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||||
|
if (procImages.value.length === 0) return { error: '请上传产品实拍图' }
|
||||||
|
if (refImages.value.length === 0) return { error: '请上传克隆参考图' }
|
||||||
|
const modeMap = { domestic: '1', amazon: '2', specify: '3' } as const
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 10,
|
||||||
|
name: productName.value.trim(),
|
||||||
|
desc: features.value.trim(),
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
language: language.value.trim() || '中文',
|
||||||
|
mode: modeMap[cloneMode.value],
|
||||||
|
proc_images: procImages.value.slice(),
|
||||||
|
ref_images: refImages.value.slice(),
|
||||||
|
}
|
||||||
|
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||||
|
if (cloneMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">产品名称 (必填)</label>
|
||||||
|
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="features"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="如: 1:保湿效果好 2:适合敏感肌 3:价格实惠"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<div class="section-title">克隆模式</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="opt-btn" :class="{ active: cloneMode === 'domestic' }" @click="cloneMode = 'domestic'">国内模式</button>
|
||||||
|
<button class="opt-btn" :class="{ active: cloneMode === 'amazon' }" @click="cloneMode = 'amazon'">亚马逊模式</button>
|
||||||
|
<button class="opt-btn" :class="{ active: cloneMode === 'specify' }" @click="cloneMode = 'specify'">指定文案</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="cloneMode === 'specify'" class="option-group">
|
||||||
|
<label class="section-title">每张参考图对应文案 (可选)</label>
|
||||||
|
<SpecifyTextList v-model="specify" :count="refImages.length" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<div class="section-title">画幅比例</div>
|
||||||
|
<div class="btn-group btn-group-ratio-grid">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS_AUTO"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint-text">💡 克隆模式强烈建议使用auto自动比例</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<div class="section-title">分辨率 (RESOLUTION)</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||||
|
<input v-model="language" type="text" class="form-input" placeholder="例如: 中文输出、英文输出、中英混合" />
|
||||||
|
</div>
|
||||||
|
<ImageListField v-model="procImages" :max="6" title="产品实拍图" :paste-pos="'row'" />
|
||||||
|
<ImageListField v-model="refImages" :max="14" title="克隆参考图" :paste-pos="'row'" />
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import SingleImageField from '../SingleImageField.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ClonePosterPanel' })
|
||||||
|
|
||||||
|
const mainTitle = ref('')
|
||||||
|
const subtitle = ref('')
|
||||||
|
const ratio = ref('16:9')
|
||||||
|
const res = ref('2k')
|
||||||
|
const layoutImage = ref<string | null>(null)
|
||||||
|
const images = ref<string[]>([])
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (!layoutImage.value) return { error: '请上传版式图片(1张)' }
|
||||||
|
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 4,
|
||||||
|
name: mainTitle.value.trim() || '产品',
|
||||||
|
desc: subtitle.value.trim(),
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
count: 1,
|
||||||
|
layout_image: layoutImage.value,
|
||||||
|
ref_images: images.value.slice(),
|
||||||
|
}
|
||||||
|
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">主标题</label>
|
||||||
|
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">副标题</label>
|
||||||
|
<textarea
|
||||||
|
v-model="subtitle"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="如: 1: 保湿效果好 2: 适合敏感肌 3: 价格实惠"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">画幅比例</label>
|
||||||
|
<div class="btn-group btn-group-ratio-grid btn-group-ratio-4col">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SingleImageField v-model="layoutImage" title="上传版式 (1张)" zone-text="上传版式" />
|
||||||
|
<ImageListField v-model="images" :max="6" title="上传图片 (最多6张)" />
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ClothingDetailPanel' })
|
||||||
|
|
||||||
|
const productName = ref('')
|
||||||
|
const features = ref('')
|
||||||
|
const ratio = ref('16:9')
|
||||||
|
const res = ref('2k')
|
||||||
|
const style = ref('')
|
||||||
|
const language = ref('')
|
||||||
|
const count = ref(9)
|
||||||
|
const images = ref<string[]>([])
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||||
|
if (images.value.length === 0) return { error: '请上传服装细节图片' }
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 5,
|
||||||
|
name: productName.value.trim(),
|
||||||
|
desc: features.value.trim(),
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
count: count.value,
|
||||||
|
style: style.value || '极简高级',
|
||||||
|
language: language.value.trim() || '中文',
|
||||||
|
ref_images: images.value.slice(),
|
||||||
|
}
|
||||||
|
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">产品名称(必填)</label>
|
||||||
|
<input v-model="productName" type="text" class="form-input" placeholder="如:口红吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="features"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="如:1:保湿效果好 2:适合敏感肌 3:价格实惠"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">画幅比例</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">风格选择 <span class="title-icon">✦</span></label>
|
||||||
|
<input v-model="style" class="form-select" list="styleList_clothes" placeholder="-- 选择或输入风格 --" />
|
||||||
|
<datalist id="styleList_clothes">
|
||||||
|
<option value="极简高级"></option>
|
||||||
|
<option value="时尚潮流"></option>
|
||||||
|
<option value="复古颗粒"></option>
|
||||||
|
<option value="网红外拍"></option>
|
||||||
|
<option value="手机自拍"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||||
|
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">生成张数 <span class="title-icon">🖼</span></label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="c in COUNT_DETAIL"
|
||||||
|
:key="c"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: count === c }"
|
||||||
|
@click="count = c"
|
||||||
|
>
|
||||||
|
{{ c }}张
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageListField
|
||||||
|
v-model="images"
|
||||||
|
:max="8"
|
||||||
|
title="服装细节(至少1张)"
|
||||||
|
:paste-pos="'below'"
|
||||||
|
/>
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ExtremeDetailPanel' })
|
||||||
|
|
||||||
|
const DEFAULT_MANUAL = `1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌
|
||||||
|
2. 加宽腰头无痕边: 不勒肚不卡档, 自在无束缚
|
||||||
|
3. 0荧光0甲醛: 严守安全标准, 贴身穿着才安心
|
||||||
|
4. 萌趣卡通印花: 色彩清新柔和, 孩子一眼就爱
|
||||||
|
5. 立体透气抑菌档: 吸湿排汗强, 干爽舒适不闷
|
||||||
|
6. 高弹面料不松垮: 耐洗不易变形, 久穿不起球
|
||||||
|
7. 2-12岁全尺码: 剪裁贴合身形, 舒适贴合不紧
|
||||||
|
8. 弹力平角不卡腿: 穿脱顺畅方便, 孩子自己穿
|
||||||
|
9. 多种花色随心选: 满足日常替换, 天天不重样`
|
||||||
|
|
||||||
|
const productName = ref('')
|
||||||
|
const featureMode = ref<'auto' | 'manual' | 'specify'>('manual')
|
||||||
|
const manualText = ref(DEFAULT_MANUAL)
|
||||||
|
const ratio = ref('3:4')
|
||||||
|
const res = ref('2k')
|
||||||
|
const styleDesc = ref('')
|
||||||
|
const language = ref('')
|
||||||
|
const count = ref(9)
|
||||||
|
const images = ref<string[]>([])
|
||||||
|
const specify = ref<string[]>([])
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||||
|
if (images.value.length === 0) return { error: '请上传参考图片' }
|
||||||
|
const modeMap = { auto: '1', manual: '1', specify: '3' } as const
|
||||||
|
let desc = ''
|
||||||
|
if (featureMode.value === 'manual') desc = manualText.value.trim()
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 8,
|
||||||
|
name: productName.value.trim(),
|
||||||
|
desc,
|
||||||
|
style: styleDesc.value.trim() || '极简高级',
|
||||||
|
language: language.value.trim() || '中文',
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
count: count.value,
|
||||||
|
mode: modeMap[featureMode.value],
|
||||||
|
ref_images: images.value.slice(),
|
||||||
|
}
|
||||||
|
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||||
|
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">产品名称(必填)</label>
|
||||||
|
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||||
|
<div class="feature-mode-tabs btn-group">
|
||||||
|
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
|
||||||
|
<button class="opt-btn" :class="{ active: featureMode === 'manual' }" @click="featureMode = 'manual'">手动模式</button>
|
||||||
|
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="featureMode === 'manual'" class="feature-mode-content">
|
||||||
|
<div class="feature-example-label">示例2:</div>
|
||||||
|
<textarea
|
||||||
|
v-model="manualText"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="10"
|
||||||
|
placeholder="每行一个特点,格式如:1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="featureMode === 'specify'" class="feature-mode-content">
|
||||||
|
<SpecifyTextList v-model="specify" :count="count" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">画幅比例 (ASPECT RATIO)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">风格和功能卖点描述</label>
|
||||||
|
<input v-model="styleDesc" type="text" class="form-input" placeholder="例如:高级质感、极简主义、奢华轻奢" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||||
|
<input v-model="language" type="text" class="form-input" placeholder="例如:使用中文输出、使用英文输出" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">生成张数</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="c in COUNT_DETAIL"
|
||||||
|
:key="c"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: count === c }"
|
||||||
|
@click="count = c"
|
||||||
|
>
|
||||||
|
{{ c }}张
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageListField
|
||||||
|
v-model="images"
|
||||||
|
:max="8"
|
||||||
|
title="参考图片 (最多8张)"
|
||||||
|
hint="建议包含正面及多角度图"
|
||||||
|
:paste-pos="'row'"
|
||||||
|
/>
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { COUNT_MAIN, RATIO_OPTIONS_MAIN, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MainImagePanel' })
|
||||||
|
|
||||||
|
const props = defineProps<{ panelId: 'productMainImage' | 'buyerShow' }>()
|
||||||
|
|
||||||
|
const productName = ref('')
|
||||||
|
const featureMode = ref<'auto' | 'specify'>('auto')
|
||||||
|
const ratio = ref('3:4')
|
||||||
|
const res = ref('2k')
|
||||||
|
const count = ref(1)
|
||||||
|
const styleDesc = ref('')
|
||||||
|
const language = ref('')
|
||||||
|
const images = ref<string[]>([])
|
||||||
|
const specify = ref<string[]>([])
|
||||||
|
|
||||||
|
const menu = computed(() => (props.panelId === 'buyerShow' ? 12 : 9))
|
||||||
|
|
||||||
|
function collectModelImages(): string[] | null {
|
||||||
|
return modelImages.value.length > 0 ? modelImages.value.slice() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||||
|
if (images.value.length === 0) return { error: '请上传产品图片' }
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: menu.value,
|
||||||
|
name: productName.value.trim(),
|
||||||
|
desc: '',
|
||||||
|
style: styleDesc.value.trim() || '白底图',
|
||||||
|
language: language.value.trim() || '中文',
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
count: count.value,
|
||||||
|
mode: featureMode.value === 'specify' ? '3' : '1',
|
||||||
|
ref_images: images.value.slice(),
|
||||||
|
}
|
||||||
|
const modelImages = collectModelImages()
|
||||||
|
if (modelImages) params.model_images = modelImages
|
||||||
|
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">产品名称(必填)</label>
|
||||||
|
<input v-model="productName" type="text" class="form-input" placeholder="如:口红 吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||||
|
<div class="feature-mode-tabs btn-group">
|
||||||
|
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
|
||||||
|
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="featureMode === 'specify'" class="feature-mode-content">
|
||||||
|
<SpecifyTextList v-model="specify" :count="count" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">画幅比例</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS_MAIN"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">风格描述 (可选)</label>
|
||||||
|
<input v-model="styleDesc" type="text" class="form-input" placeholder="如:白底图 浅色调 墨绿色调 户外 居家" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||||
|
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">生成张数</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="c in COUNT_MAIN"
|
||||||
|
:key="c"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: count === c }"
|
||||||
|
@click="count = c"
|
||||||
|
>
|
||||||
|
{{ c }}张
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageListField
|
||||||
|
v-model="images"
|
||||||
|
:max="8"
|
||||||
|
title="产品图片 (最多8张)"
|
||||||
|
hint="建议包含正面及多角度图"
|
||||||
|
:paste-pos="'row'"
|
||||||
|
/>
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||||
|
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||||
|
import { modelImages } from '../../workbench-shared'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProductPosterPanel' })
|
||||||
|
|
||||||
|
const mainTitle = ref('')
|
||||||
|
const subtitle = ref('')
|
||||||
|
const brandName = ref('')
|
||||||
|
const ingredients = ref('')
|
||||||
|
const activity = ref('')
|
||||||
|
const ratio = ref('16:9')
|
||||||
|
const res = ref('2k')
|
||||||
|
const images = ref<string[]>([])
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 6,
|
||||||
|
name: mainTitle.value.trim() || '产品',
|
||||||
|
desc: subtitle.value.trim(),
|
||||||
|
brand_name: brandName.value.trim(),
|
||||||
|
Ingredients: ingredients.value.trim(),
|
||||||
|
activity: activity.value.trim(),
|
||||||
|
ratio: ratio.value,
|
||||||
|
resolution: res.value,
|
||||||
|
count: 1,
|
||||||
|
ref_images: images.value.slice(),
|
||||||
|
}
|
||||||
|
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">主标题</label>
|
||||||
|
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红吹风机 美容仪 美妆包" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">副标题</label>
|
||||||
|
<textarea
|
||||||
|
v-model="subtitle"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="如: 1:保湿效果好 2: 适合敏感肌 3:价格实惠"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">品牌名 (可选)</label>
|
||||||
|
<input v-model="brandName" type="text" class="form-input" placeholder="(可选)" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">成分 (INGREDIENTS)</label>
|
||||||
|
<input v-model="ingredients" type="text" class="form-input" placeholder="例如: 纯棉、透明质酸...(可选)" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">活动 (ACTIVITY)</label>
|
||||||
|
<input v-model="activity" type="text" class="form-input" placeholder="例如: 买一送一、限时折扣...(可选)" />
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">画幅比例</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="r in RATIO_OPTIONS"
|
||||||
|
:key="r"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: ratio === r }"
|
||||||
|
@click="ratio = r"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
v-for="opt in RES_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="opt-btn"
|
||||||
|
:class="{ active: res === opt.value }"
|
||||||
|
@click="res = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageListField
|
||||||
|
v-model="images"
|
||||||
|
:max="6"
|
||||||
|
title="上传图片 (最多6张)"
|
||||||
|
hint="支持多选与拖拽排序"
|
||||||
|
:paste-pos="'row'"
|
||||||
|
/>
|
||||||
|
<ModelImagesUploader />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImageListField from '../ImageListField.vue'
|
||||||
|
import SingleImageField from '../SingleImageField.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TextToImagePanel' })
|
||||||
|
|
||||||
|
const prompt = ref('')
|
||||||
|
const refImages = ref<string[]>([])
|
||||||
|
const video = ref<string | null>(null)
|
||||||
|
|
||||||
|
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
menu: 1,
|
||||||
|
prompt: prompt.value.trim() || '',
|
||||||
|
ref_images: refImages.value.slice(),
|
||||||
|
}
|
||||||
|
if (video.value) params.video = video.value
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ buildParams })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="option-group">
|
||||||
|
<div class="section-title">提示词</div>
|
||||||
|
<div class="textarea-wrap">
|
||||||
|
<textarea
|
||||||
|
v-model="prompt"
|
||||||
|
class="prompt-textarea prompt-textarea-large"
|
||||||
|
placeholder="输入自定义指令,用于反推提示词..."
|
||||||
|
></textarea>
|
||||||
|
<span class="char-count char-count-top-right">{{ prompt.length }} 字符</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ImageListField
|
||||||
|
v-model="refImages"
|
||||||
|
:max="8"
|
||||||
|
title="上传图片(最多8张)"
|
||||||
|
hint="支持JPG PNG BMP JPEG"
|
||||||
|
:zone-hint="'支持多选/拖拽上传'"
|
||||||
|
/>
|
||||||
|
<SingleImageField
|
||||||
|
v-model="video"
|
||||||
|
title="上传视频(只支持1个)"
|
||||||
|
hint="支持 MP4 等视频格式"
|
||||||
|
zone-text="点击上传视频"
|
||||||
|
accept="video/*,.mp4,.webm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
|
||||||
|
|
||||||
|
/** 读取多个文件为 data URL,返回解析完的数组。 */
|
||||||
|
export function readFilesAsDataUrls(files: Blob[] | FileList): Promise<string[]> {
|
||||||
|
const list = Array.from(files)
|
||||||
|
return Promise.all(
|
||||||
|
list.map(
|
||||||
|
(file) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''))
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('读取文件失败'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从剪贴板读取图片文件。 */
|
||||||
|
export async function pasteClipboardImages(): Promise<Blob[]> {
|
||||||
|
try {
|
||||||
|
const items = await navigator.clipboard.read()
|
||||||
|
const images: Blob[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.types.includes('image/png')) {
|
||||||
|
images.push(await item.getType('image/png'))
|
||||||
|
} else if (item.types.includes('image/jpeg')) {
|
||||||
|
images.push(await item.getType('image/jpeg'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('粘贴图片失败:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
export const RES_OPTIONS = [
|
||||||
|
{ label: '2K', value: '2k' },
|
||||||
|
{ label: '4K', value: '4k' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 不含 auto 的常规画幅比例 */
|
||||||
|
export const RATIO_OPTIONS = ['3:4', '1:1', '16:9', '9:16', '4:3', '2:3', '3:2', '21:9']
|
||||||
|
|
||||||
|
/** 含 auto 的画幅比例 */
|
||||||
|
export const RATIO_OPTIONS_AUTO = ['auto', ...RATIO_OPTIONS]
|
||||||
|
|
||||||
|
/** 产品主图/买家秀仅三种比例,默认 3:4 */
|
||||||
|
export const RATIO_OPTIONS_MAIN = ['1:1', '3:4', '4:3']
|
||||||
|
|
||||||
|
export const COUNT_MAIN = [1, 3, 5, 7, 9]
|
||||||
|
|
||||||
|
export const COUNT_DETAIL = [3, 5, 9, 14]
|
||||||
|
|
||||||
|
export function collectSpecify(list: string[]): string[] {
|
||||||
|
return (Array.isArray(list) ? list : []).map((s) => String(s || '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** 多模特图:除反推词外所有栏目共享同一组上传 */
|
||||||
|
export const modelImages = ref<string[]>([])
|
||||||
|
|
||||||
|
export const STORAGE_API_KEY = 'maixiang_api_key'
|
||||||
|
export const STORAGE_AUTO_SAVE_PATH = 'maixiang_auto_save_path'
|
||||||
@@ -53,7 +53,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { resolvePageHref } from '@/shared/page-prefix'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { loginWithDevice } from '@/shared/api/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||||
const DEVICE_ID_KEY = 'aiimage_device_id'
|
const DEVICE_ID_KEY = 'aiimage_device_id'
|
||||||
@@ -68,10 +71,6 @@ function togglePassword() {
|
|||||||
passwordVisible.value = !passwordVisible.value
|
passwordVisible.value = !passwordVisible.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDesktopClient() {
|
|
||||||
return Boolean(window.pywebview?.api) || window.location.pathname === '/login'
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAppPermissionCaches() {
|
function clearAppPermissionCaches() {
|
||||||
try {
|
try {
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
@@ -137,17 +136,8 @@ async function submitLogin() {
|
|||||||
errorMessage.value = '未获取到设备ID,请在桌面端打开'
|
errorMessage.value = '未获取到设备ID,请在桌面端打开'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const loginResp = await window.fetch('/newApi/login', {
|
// 经 shared/api/user 层登录(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
|
||||||
method: 'POST',
|
const loginBody = await loginWithDevice(account, password.value, deviceId)
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Device-Id': deviceId,
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username: account, password: password.value, deviceId }),
|
|
||||||
})
|
|
||||||
const loginBody = await loginResp.json().catch(() => ({}))
|
|
||||||
if (!loginBody || loginBody.success !== true || !loginBody.data) {
|
if (!loginBody || loginBody.success !== true || !loginBody.data) {
|
||||||
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
|
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
|
||||||
return
|
return
|
||||||
@@ -174,20 +164,10 @@ async function submitLogin() {
|
|||||||
/* 忽略 */
|
/* 忽略 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 把 Java 签发的 JWT 同步到 Python 同源 cookie,供后续页面跳转携带
|
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorage(JWT 走 Bearer),无 cookie 依赖
|
||||||
try {
|
|
||||||
await window.fetch('/api/auth/sync', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: data.token }),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
/* cookie 同步失败不阻塞跳转(页面仍可读 localStorage) */
|
|
||||||
}
|
|
||||||
clearAppPermissionCaches()
|
clearAppPermissionCaches()
|
||||||
// 桌面端走 Flask /home;Web 独立部署(路径含 /new_web_source)与 dev 走 resolvePageHref 归一
|
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router)
|
||||||
window.location.href = isDesktopClient() ? '/home' : resolvePageHref('/new_web_source/home.html')
|
await router.replace('/home')
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = '网络异常,请稍后重试'
|
errorMessage.value = '网络异常,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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
|
desc?: string
|
||||||
file_paths?: string[]
|
file_paths?: string[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
/** 最后更新(终态时即完成时间,作为结束时间兜底) */
|
||||||
|
updated_at?: string
|
||||||
progress_total?: number
|
progress_total?: number
|
||||||
progress_current?: number
|
progress_current?: number
|
||||||
error_message?: string
|
error_message?: string
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export interface ConvertResultItem {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
/** 所属任务ID(biz_file_task.id) */
|
||||||
|
taskId?: number;
|
||||||
|
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||||
|
taskStatus?: string;
|
||||||
|
/** 任务创建时间 */
|
||||||
|
createdAt?: string;
|
||||||
|
/** 任务开始时间 */
|
||||||
|
startedAt?: string;
|
||||||
|
/** 任务结束时间(未结束为空) */
|
||||||
|
finishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConvertRunVo {
|
export interface ConvertRunVo {
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export interface DedupeResultItem {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
/** 所属任务ID(biz_file_task.id) */
|
||||||
|
taskId?: number;
|
||||||
|
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||||
|
taskStatus?: string;
|
||||||
|
/** 任务创建时间 */
|
||||||
|
createdAt?: string;
|
||||||
|
/** 任务开始时间 */
|
||||||
|
startedAt?: string;
|
||||||
|
/** 任务结束时间(未结束为空) */
|
||||||
|
finishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DedupeRunVo {
|
export interface DedupeRunVo {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user