Compare commits
2 Commits
967eedcad7
...
c3d43aa26a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3d43aa26a | ||
|
|
b0462df3c1 |
@@ -29,4 +29,13 @@ public class CollectDataSubmitRowDto {
|
|||||||
@JsonAlias({"关键词", "key_word"})
|
@JsonAlias({"关键词", "key_word"})
|
||||||
@Schema(description = "关键词", example = "phone case")
|
@Schema(description = "关键词", example = "phone case")
|
||||||
private String keyword;
|
private String keyword;
|
||||||
|
|
||||||
|
@JsonProperty("delivery_method")
|
||||||
|
@JsonAlias({"配送方式", "deliveryMethod", "delivery", "fulfillment"})
|
||||||
|
@Schema(description = "配送方式:FBA / FBM / AMZ;未识别时由后端归入“无配送方式”", example = "FBA")
|
||||||
|
private String deliveryMethod;
|
||||||
|
|
||||||
|
@JsonAlias({"页数", "page", "page_no", "pageNo", "page_index", "pageIndex"})
|
||||||
|
@Schema(description = "该行数据所属页码(来自 Python 抓取端的搜索结果页码),后端按关键词取最大值作为总页数", example = "3")
|
||||||
|
private Integer page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public class CollectDataResultRowVo {
|
|||||||
private String price;
|
private String price;
|
||||||
private String sellerName;
|
private String sellerName;
|
||||||
private String keyword;
|
private String keyword;
|
||||||
|
private String deliveryMethod;
|
||||||
|
private Integer page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,38 +10,34 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CollectDataExcelAssemblyService {
|
public class CollectDataExcelAssemblyService {
|
||||||
|
|
||||||
private static final String[] HEADER = {"品牌", "ASIN", "价格", "卖家名称", "关键词"};
|
private static final String SHEET_DETAIL_NAME = "采集数据结果";
|
||||||
|
private static final String[] SHEET_DETAIL_HEADER = {"品牌", "ASIN", "价格", "卖家名称", "关键词", "配送方式"};
|
||||||
|
|
||||||
public void writeWorkbook(File outputXlsx, List<CollectDataResultRowVo> items) {
|
private static final String SHEET_SUMMARY_NAME = "结果文件";
|
||||||
|
private static final String[] SHEET_SUMMARY_HEADER = {"关键词", "FBA", "FBM", "AMZ", "无配送方式", "页数"};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成采集结果工作簿。
|
||||||
|
*
|
||||||
|
* @param outputXlsx 目标文件
|
||||||
|
* @param items 经过 ASIN 去重 / 无效品牌过滤 / 品牌检测后保留下来的明细行,写入「采集数据结果」sheet
|
||||||
|
* @param rawItems Python 回传的全量原始行(不经后端任何过滤),写入「结果文件」sheet 的关键词聚合统计
|
||||||
|
*/
|
||||||
|
public void writeWorkbook(File outputXlsx, List<CollectDataResultRowVo> items, List<CollectDataResultRowVo> rawItems) {
|
||||||
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
|
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
|
||||||
workbook.setCompressTempFiles(true);
|
workbook.setCompressTempFiles(true);
|
||||||
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
||||||
Sheet sheet = workbook.createSheet("采集数据结果");
|
writeDetailSheet(workbook, items);
|
||||||
Row headerRow = sheet.createRow(0);
|
writeSummarySheet(workbook, rawItems);
|
||||||
for (int i = 0; i < HEADER.length; i++) {
|
|
||||||
headerRow.createCell(i).setCellValue(HEADER[i]);
|
|
||||||
sheet.setColumnWidth(i, (i == 3 ? 24 : 18) * 256);
|
|
||||||
}
|
|
||||||
int rowIndex = 1;
|
|
||||||
if (items != null) {
|
|
||||||
for (CollectDataResultRowVo item : items) {
|
|
||||||
if (item == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Row row = sheet.createRow(rowIndex++);
|
|
||||||
row.createCell(0).setCellValue(safe(item.getBrand()));
|
|
||||||
row.createCell(1).setCellValue(safe(item.getAsin()));
|
|
||||||
row.createCell(2).setCellValue(safe(item.getPrice()));
|
|
||||||
row.createCell(3).setCellValue(safe(item.getSellerName()));
|
|
||||||
row.createCell(4).setCellValue(safe(item.getKeyword()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workbook.write(outputStream);
|
workbook.write(outputStream);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[collect-data] write workbook failed: {}", ex.getMessage());
|
log.warn("[collect-data] write workbook failed: {}", ex.getMessage());
|
||||||
@@ -55,7 +51,91 @@ public class CollectDataExcelAssemblyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void writeDetailSheet(SXSSFWorkbook workbook, List<CollectDataResultRowVo> items) {
|
||||||
|
Sheet sheet = workbook.createSheet(SHEET_DETAIL_NAME);
|
||||||
|
Row headerRow = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < SHEET_DETAIL_HEADER.length; i++) {
|
||||||
|
headerRow.createCell(i).setCellValue(SHEET_DETAIL_HEADER[i]);
|
||||||
|
sheet.setColumnWidth(i, (i == 3 ? 24 : 18) * 256);
|
||||||
|
}
|
||||||
|
int rowIndex = 1;
|
||||||
|
if (items != null) {
|
||||||
|
for (CollectDataResultRowVo item : items) {
|
||||||
|
if (item == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Row row = sheet.createRow(rowIndex++);
|
||||||
|
row.createCell(0).setCellValue(safe(item.getBrand()));
|
||||||
|
row.createCell(1).setCellValue(safe(item.getAsin()));
|
||||||
|
row.createCell(2).setCellValue(safe(item.getPrice()));
|
||||||
|
row.createCell(3).setCellValue(safe(item.getSellerName()));
|
||||||
|
row.createCell(4).setCellValue(safe(item.getKeyword()));
|
||||||
|
row.createCell(5).setCellValue(safe(item.getDeliveryMethod()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「结果文件」sheet 基于 Python 回传的全量原始数据按关键词聚合:
|
||||||
|
* - FBA / FBM / AMZ / 无配送方式 列为各分类的命中行数;
|
||||||
|
* - 页数列取该关键词所有原始行中页码的最大值,反映 Python 实际抓取到的总页数。
|
||||||
|
*/
|
||||||
|
private void writeSummarySheet(SXSSFWorkbook workbook, List<CollectDataResultRowVo> rawItems) {
|
||||||
|
Sheet sheet = workbook.createSheet(SHEET_SUMMARY_NAME);
|
||||||
|
Row headerRow = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < SHEET_SUMMARY_HEADER.length; i++) {
|
||||||
|
headerRow.createCell(i).setCellValue(SHEET_SUMMARY_HEADER[i]);
|
||||||
|
sheet.setColumnWidth(i, (i == 0 ? 28 : 12) * 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, KeywordSummary> grouped = new LinkedHashMap<>();
|
||||||
|
if (rawItems != null) {
|
||||||
|
for (CollectDataResultRowVo item : rawItems) {
|
||||||
|
if (item == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String keyword = safe(item.getKeyword());
|
||||||
|
KeywordSummary summary = grouped.computeIfAbsent(keyword, k -> new KeywordSummary());
|
||||||
|
String delivery = item.getDeliveryMethod() == null ? "" : item.getDeliveryMethod().trim().toUpperCase(Locale.ROOT);
|
||||||
|
switch (delivery) {
|
||||||
|
case "FBA" -> summary.fba++;
|
||||||
|
case "FBM" -> summary.fbm++;
|
||||||
|
case "AMZ" -> summary.amz++;
|
||||||
|
default -> summary.none++;
|
||||||
|
}
|
||||||
|
Integer page = item.getPage();
|
||||||
|
if (page != null && page > summary.maxPage) {
|
||||||
|
summary.maxPage = page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int rowIndex = 1;
|
||||||
|
for (Map.Entry<String, KeywordSummary> entry : grouped.entrySet()) {
|
||||||
|
KeywordSummary summary = entry.getValue();
|
||||||
|
Row row = sheet.createRow(rowIndex++);
|
||||||
|
row.createCell(0).setCellValue(entry.getKey());
|
||||||
|
row.createCell(1).setCellValue(summary.fba);
|
||||||
|
row.createCell(2).setCellValue(summary.fbm);
|
||||||
|
row.createCell(3).setCellValue(summary.amz);
|
||||||
|
row.createCell(4).setCellValue(summary.none);
|
||||||
|
if (summary.maxPage > 0) {
|
||||||
|
row.createCell(5).setCellValue(summary.maxPage);
|
||||||
|
} else {
|
||||||
|
row.createCell(5).setCellValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String safe(String value) {
|
private String safe(String value) {
|
||||||
return value == null ? "" : value;
|
return value == null ? "" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class KeywordSummary {
|
||||||
|
int fba;
|
||||||
|
int fbm;
|
||||||
|
int amz;
|
||||||
|
int none;
|
||||||
|
int maxPage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,7 +431,16 @@ public class CollectDataService {
|
|||||||
stats.receivedRows += rows.size();
|
stats.receivedRows += rows.size();
|
||||||
stats.currentChunkRows = rows.size();
|
stats.currentChunkRows = rows.size();
|
||||||
|
|
||||||
List<CollectDataResultRowVo> candidates = filterByExistingAsin(rows, stats);
|
// 「结果文件」sheet 需要 Python 回传的全量数据(不经任何后端过滤),
|
||||||
|
// 因此 chunk payload 写入未筛除空 ASIN 的全量 rows;
|
||||||
|
// 仅当 ASIN 非空时才进入去重 / 无效品牌 / 品牌检测链路并落 biz_task_result_item。
|
||||||
|
List<CollectDataResultRowVo> rowsForFiltering = new ArrayList<>(rows.size());
|
||||||
|
for (CollectDataResultRowVo row : rows) {
|
||||||
|
if (row.getAsin() != null && !row.getAsin().isBlank()) {
|
||||||
|
rowsForFiltering.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<CollectDataResultRowVo> candidates = filterByExistingAsin(rowsForFiltering, stats);
|
||||||
List<CollectDataResultRowVo> accepted = filterByBrandCheck(candidates, stats);
|
List<CollectDataResultRowVo> accepted = filterByBrandCheck(candidates, stats);
|
||||||
for (CollectDataResultRowVo row : accepted) {
|
for (CollectDataResultRowVo row : accepted) {
|
||||||
upsertResultItem(task.getId(), result.getId(), scopeKey, row);
|
upsertResultItem(task.getId(), result.getId(), scopeKey, row);
|
||||||
@@ -483,6 +492,11 @@ public class CollectDataService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 Python 回传的原始行映射到 VO。**保留全量数据**(包含 ASIN 为空的行),
|
||||||
|
* 因为「结果文件」sheet 需要不经过任何过滤的全量统计;ASIN 为空的行
|
||||||
|
* 仅在 chunk payload 中保留,下游过滤链由调用方按 ASIN 非空再行筛选。
|
||||||
|
*/
|
||||||
private List<CollectDataResultRowVo> normalizeSubmitRows(List<CollectDataSubmitRowDto> rawRows) {
|
private List<CollectDataResultRowVo> normalizeSubmitRows(List<CollectDataSubmitRowDto> rawRows) {
|
||||||
List<CollectDataResultRowVo> rows = new ArrayList<>();
|
List<CollectDataResultRowVo> rows = new ArrayList<>();
|
||||||
if (rawRows == null) {
|
if (rawRows == null) {
|
||||||
@@ -492,16 +506,14 @@ public class CollectDataService {
|
|||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String asin = normalizeAsin(raw.getAsin());
|
|
||||||
if (asin.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
CollectDataResultRowVo row = new CollectDataResultRowVo();
|
CollectDataResultRowVo row = new CollectDataResultRowVo();
|
||||||
row.setBrand(normalize(raw.getBrand()));
|
row.setBrand(normalize(raw.getBrand()));
|
||||||
row.setAsin(asin);
|
row.setAsin(normalizeAsin(raw.getAsin()));
|
||||||
row.setPrice(normalize(raw.getPrice()));
|
row.setPrice(normalize(raw.getPrice()));
|
||||||
row.setSellerName(normalize(raw.getSellerName()));
|
row.setSellerName(normalize(raw.getSellerName()));
|
||||||
row.setKeyword(normalize(raw.getKeyword()));
|
row.setKeyword(normalize(raw.getKeyword()));
|
||||||
|
row.setDeliveryMethod(normalizeDeliveryMethod(raw.getDeliveryMethod()));
|
||||||
|
row.setPage(raw.getPage());
|
||||||
rows.add(row);
|
rows.add(row);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -525,25 +537,34 @@ public class CollectDataService {
|
|||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Set<String> invalidValues = new HashSet<>();
|
List<String> brands = rows.stream()
|
||||||
if (!asins.isEmpty()) {
|
.map(row -> normalizeBrand(row.getBrand()))
|
||||||
|
.filter(value -> !value.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
Set<String> invalidBrands = new HashSet<>();
|
||||||
|
if (!brands.isEmpty()) {
|
||||||
List<InvalidAsinDataEntity> invalidRows = invalidAsinDataMapper.selectList(new LambdaQueryWrapper<InvalidAsinDataEntity>()
|
List<InvalidAsinDataEntity> invalidRows = invalidAsinDataMapper.selectList(new LambdaQueryWrapper<InvalidAsinDataEntity>()
|
||||||
.in(InvalidAsinDataEntity::getDataValue, asins));
|
.select(InvalidAsinDataEntity::getBrand)
|
||||||
|
.in(InvalidAsinDataEntity::getBrand, brands));
|
||||||
if (invalidRows != null) {
|
if (invalidRows != null) {
|
||||||
for (InvalidAsinDataEntity row : invalidRows) {
|
for (InvalidAsinDataEntity row : invalidRows) {
|
||||||
invalidValues.add(normalizeAsin(row.getDataValue()));
|
String normalized = normalizeBrand(row.getBrand());
|
||||||
|
if (!normalized.isBlank()) {
|
||||||
|
invalidBrands.add(normalized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<CollectDataResultRowVo> out = new ArrayList<>();
|
List<CollectDataResultRowVo> out = new ArrayList<>();
|
||||||
for (CollectDataResultRowVo row : rows) {
|
for (CollectDataResultRowVo row : rows) {
|
||||||
String asin = row.getAsin();
|
if (dedupeValues.contains(row.getAsin())) {
|
||||||
if (dedupeValues.contains(asin)) {
|
|
||||||
stats.dedupeFilteredCount++;
|
stats.dedupeFilteredCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (invalidValues.contains(asin)) {
|
String brand = normalizeBrand(row.getBrand());
|
||||||
|
if (!brand.isBlank() && invalidBrands.contains(brand)) {
|
||||||
stats.invalidFilteredCount++;
|
stats.invalidFilteredCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -624,9 +645,13 @@ public class CollectDataService {
|
|||||||
if (row == null || row.getAsin() == null || row.getAsin().isBlank()) {
|
if (row == null || row.getAsin() == null || row.getAsin().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String brand = normalizeBrand(row.getBrand());
|
||||||
|
if (brand.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
|
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
|
||||||
entity.setDataValue(row.getAsin());
|
entity.setDataValue(row.getAsin());
|
||||||
entity.setBrand(row.getBrand());
|
entity.setBrand(brand);
|
||||||
try {
|
try {
|
||||||
invalidAsinDataMapper.insert(entity);
|
invalidAsinDataMapper.insert(entity);
|
||||||
} catch (DuplicateKeyException ignored) {
|
} catch (DuplicateKeyException ignored) {
|
||||||
@@ -788,11 +813,14 @@ public class CollectDataService {
|
|||||||
throw new BusinessException("结果记录不存在");
|
throw new BusinessException("结果记录不存在");
|
||||||
}
|
}
|
||||||
List<CollectDataResultRowVo> rows = loadFinalRows(task.getId());
|
List<CollectDataResultRowVo> rows = loadFinalRows(task.getId());
|
||||||
|
// Sheet「结果文件」按需求基于 Python 回传的全量数据聚合,不经后端 ASIN/品牌过滤丢弃,
|
||||||
|
// 因此从 biz_task_chunk 反序列化全部原始行。
|
||||||
|
List<CollectDataResultRowVo> rawRows = loadRawRows(task.getId());
|
||||||
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "collect-data-result", String.valueOf(task.getId())));
|
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "collect-data-result", String.valueOf(task.getId())));
|
||||||
String filename = buildResultFilename(task, result);
|
String filename = buildResultFilename(task, result);
|
||||||
File xlsx = FileUtil.file(workRoot, filename);
|
File xlsx = FileUtil.file(workRoot, filename);
|
||||||
try {
|
try {
|
||||||
excelAssemblyService.writeWorkbook(xlsx, rows);
|
excelAssemblyService.writeWorkbook(xlsx, rows, rawRows);
|
||||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||||
result.setResultFilename(filename);
|
result.setResultFilename(filename);
|
||||||
result.setResultFileUrl(objectKey);
|
result.setResultFileUrl(objectKey);
|
||||||
@@ -841,6 +869,45 @@ public class CollectDataService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 Python 回传的全量原始行,不做 ASIN / 品牌过滤。
|
||||||
|
* 数据源是 biz_task_chunk 中按 chunk_index 顺序保存的原始 payload,
|
||||||
|
* 用于「结果文件」sheet 中按关键词聚合统计配送方式与页数。
|
||||||
|
*/
|
||||||
|
private List<CollectDataResultRowVo> loadRawRows(Long taskId) {
|
||||||
|
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.orderByAsc(TaskChunkEntity::getChunkIndex)
|
||||||
|
.orderByAsc(TaskChunkEntity::getId));
|
||||||
|
List<CollectDataResultRowVo> out = new ArrayList<>();
|
||||||
|
if (chunks == null || chunks.isEmpty()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
TypeReference<List<CollectDataResultRowVo>> listType = new TypeReference<>() {
|
||||||
|
};
|
||||||
|
for (TaskChunkEntity chunk : chunks) {
|
||||||
|
try {
|
||||||
|
String payloadJson = transientPayloadStorageService.resolvePayload(chunk.getPayloadJson(), "read collect data raw chunk failed");
|
||||||
|
if (payloadJson == null || payloadJson.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<CollectDataResultRowVo> values = objectMapper.readValue(payloadJson, listType);
|
||||||
|
if (values != null) {
|
||||||
|
for (CollectDataResultRowVo value : values) {
|
||||||
|
if (value != null) {
|
||||||
|
out.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[collect-data] load raw chunk failed taskId={} chunkId={} err={}",
|
||||||
|
taskId, chunk.getId(), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private int countFinalRows(Long taskId) {
|
private int countFinalRows(Long taskId) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -986,6 +1053,28 @@ public class CollectDataService {
|
|||||||
return normalize(value).toLowerCase(Locale.ROOT);
|
return normalize(value).toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Python 回传的配送方式标准化为 FBA / FBM / AMZ;
|
||||||
|
* 空值或未识别值统一返回空字符串,由前端 / Excel 端展示为“无配送方式”。
|
||||||
|
*/
|
||||||
|
private String normalizeDeliveryMethod(String value) {
|
||||||
|
String normalized = normalize(value);
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String upper = normalized.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.contains("FBA")) {
|
||||||
|
return "FBA";
|
||||||
|
}
|
||||||
|
if (upper.contains("FBM")) {
|
||||||
|
return "FBM";
|
||||||
|
}
|
||||||
|
if (upper.contains("AMZ") || upper.contains("AMAZON")) {
|
||||||
|
return "AMZ";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private String buildResultFilename(FileTaskEntity task, FileResultEntity result) {
|
private String buildResultFilename(FileTaskEntity task, FileResultEntity result) {
|
||||||
String base = result == null || result.getSourceFilename() == null || result.getSourceFilename().isBlank()
|
String base = result == null || result.getSourceFilename() == null || result.getSourceFilename().isBlank()
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -47,7 +48,7 @@ public class InvalidAsinDataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public InvalidAsinDataItemVo create(InvalidAsinDataCreateRequest request) {
|
public InvalidAsinDataItemVo create(InvalidAsinDataCreateRequest request) {
|
||||||
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
||||||
String brand = normalizeOptional(request.getBrand());
|
String brand = normalizeRequired(request.getBrand(), "品牌不能为空").toLowerCase(Locale.ROOT);
|
||||||
ensureUnique(dataValue, null);
|
ensureUnique(dataValue, null);
|
||||||
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
|
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
|
||||||
entity.setDataValue(dataValue);
|
entity.setDataValue(dataValue);
|
||||||
@@ -60,7 +61,7 @@ public class InvalidAsinDataService {
|
|||||||
public InvalidAsinDataItemVo update(Long id, InvalidAsinDataUpdateRequest request) {
|
public InvalidAsinDataItemVo update(Long id, InvalidAsinDataUpdateRequest request) {
|
||||||
InvalidAsinDataEntity entity = getById(id);
|
InvalidAsinDataEntity entity = getById(id);
|
||||||
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
||||||
String brand = normalizeOptional(request.getBrand());
|
String brand = normalizeRequired(request.getBrand(), "品牌不能为空").toLowerCase(Locale.ROOT);
|
||||||
ensureUnique(dataValue, id);
|
ensureUnique(dataValue, id);
|
||||||
entity.setDataValue(dataValue);
|
entity.setDataValue(dataValue);
|
||||||
entity.setBrand(brand);
|
entity.setBrand(brand);
|
||||||
@@ -100,11 +101,6 @@ public class InvalidAsinDataService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeOptional(String value) {
|
|
||||||
String normalized = normalizeText(value);
|
|
||||||
return normalized.isEmpty() ? null : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeText(String value) {
|
private String normalizeText(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 不符合 ASIN 库的比对维度从 ASIN 切换到 brand:
|
||||||
|
-- 1. brand 为空的历史记录在新规则下不再产生、也不再参与比对,直接删除
|
||||||
|
-- 2. 存量 brand 统一转小写,与 CollectDataService.normalizeBrand 对齐
|
||||||
|
-- (历史数据写入前已经过 trim + 折叠空白,仅缺 lowerCase)
|
||||||
|
DELETE FROM biz_invalid_asin_data
|
||||||
|
WHERE brand IS NULL
|
||||||
|
OR TRIM(brand) = '';
|
||||||
|
|
||||||
|
UPDATE biz_invalid_asin_data
|
||||||
|
SET brand = LOWER(TRIM(brand))
|
||||||
|
WHERE brand <> LOWER(TRIM(brand));
|
||||||
177
frontend-vue/DESIGN.md
Normal file
177
frontend-vue/DESIGN.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
- Status: Active
|
||||||
|
- Last refreshed: 2026-06-09
|
||||||
|
- Primary product surfaces:
|
||||||
|
- `image-video.html`
|
||||||
|
- `src/pages/image-video/ImageVideoPage.vue`
|
||||||
|
- Evidence reviewed:
|
||||||
|
- `src/pages/image-video/ImageVideoPage.vue`
|
||||||
|
- `src/styles/main.css`
|
||||||
|
- `src/styles/tokens.css`
|
||||||
|
- `src/components/layout/PageShell.vue`
|
||||||
|
- `../yaoyaoAI/aiclientpureui/src/styles/main.css`
|
||||||
|
- `../yaoyaoAI/aiclientpureui/src/components/dashboard/DashboardCard.vue`
|
||||||
|
- `../yaoyaoAI/aiclientpureui/src/components/workflow/Step02AudioVideo.vue`
|
||||||
|
- `../yaoyaoAI/aiclientpureui/src/components/workflow/Step03VideoEdit.vue`
|
||||||
|
|
||||||
|
## Brand
|
||||||
|
- Personality:
|
||||||
|
- Quiet, tool-oriented, high-density AI workflow workspace
|
||||||
|
- Dark professional console rather than marketing landing page
|
||||||
|
- Trust signals:
|
||||||
|
- Structured step cards
|
||||||
|
- Stable layout grid
|
||||||
|
- Clear state summaries and output preview areas
|
||||||
|
- Avoid:
|
||||||
|
- Light hero-page styling
|
||||||
|
- Decorative floating cards wrapped inside larger cards
|
||||||
|
- Tailwind or PrimeVue-specific visual copies that do not fit Element Plus
|
||||||
|
|
||||||
|
## Product goals
|
||||||
|
- Goals:
|
||||||
|
- Add a real `带货视频` module inside `frontend-vue`
|
||||||
|
- Put `视频复刻` and `图生视频` on one page with tab switching
|
||||||
|
- Match the visual tone of `aiclientpureui`
|
||||||
|
- Extract reusable workflow components for future media modules
|
||||||
|
- Non-goals:
|
||||||
|
- Backend API integration in this phase
|
||||||
|
- Rebuilding the entire `aiclientpureui` navigation shell
|
||||||
|
- Success signals:
|
||||||
|
- The page is screenshot-ready
|
||||||
|
- Form sections are organized and legible at desktop widths
|
||||||
|
- Shared components are reusable beyond this one page
|
||||||
|
|
||||||
|
## Personas and jobs
|
||||||
|
- Primary personas:
|
||||||
|
- Internal operators creating or editing commerce videos
|
||||||
|
- Content operations staff iterating scripts, scenes, and avatars quickly
|
||||||
|
- User jobs:
|
||||||
|
- Upload source material
|
||||||
|
- Configure video generation parameters
|
||||||
|
- Adjust scene, face, script, and product settings
|
||||||
|
- Review an output summary before starting generation
|
||||||
|
- Key contexts of use:
|
||||||
|
- Desktop-heavy internal tool usage
|
||||||
|
- Repeated daily workflows with dense settings
|
||||||
|
|
||||||
|
## Information architecture
|
||||||
|
- Primary navigation:
|
||||||
|
- Keep a lightweight page header with module switch and back link
|
||||||
|
- Core routes/screens:
|
||||||
|
- `image-video.html` as the image/video workspace entry
|
||||||
|
- Content hierarchy:
|
||||||
|
- Page header and module switch
|
||||||
|
- Delivery-video tabs
|
||||||
|
- Step-oriented configuration grid
|
||||||
|
- Preview and submission area
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
- Principle 1:
|
||||||
|
- Reuse structure, not framework-specific implementation. Port the dashboard/card language from `aiclientpureui` into Element Plus-native components.
|
||||||
|
- Principle 2:
|
||||||
|
- Keep the screen directly operable. The first viewport should show the active workflow, not a placeholder entrance.
|
||||||
|
- Tradeoffs:
|
||||||
|
- Prefer a compact dark console layout over a literal flowchart reconstruction from the requirement images.
|
||||||
|
|
||||||
|
## Visual language
|
||||||
|
- Color:
|
||||||
|
- Deep neutral background with restrained blue-indigo highlights
|
||||||
|
- High-contrast text, low-contrast secondary copy
|
||||||
|
- Typography:
|
||||||
|
- Existing sans-serif stack from `tokens.css`
|
||||||
|
- Compact section headings and dense form labels
|
||||||
|
- Spacing/layout rhythm:
|
||||||
|
- 20px to 24px outer spacing
|
||||||
|
- 16px card padding
|
||||||
|
- Multi-column grid with stable min widths
|
||||||
|
- Shape/radius/elevation:
|
||||||
|
- 14px to 18px radii
|
||||||
|
- Soft border glow, minimal shadow depth
|
||||||
|
- Motion:
|
||||||
|
- Short hover and focus transitions only
|
||||||
|
- Imagery/iconography:
|
||||||
|
- Use media preview boxes and upload placeholders
|
||||||
|
- Avoid decorative illustrations
|
||||||
|
|
||||||
|
## Components
|
||||||
|
- Existing components to reuse:
|
||||||
|
- `src/components/layout/PageShell.vue`
|
||||||
|
- New/changed components:
|
||||||
|
- Shared dark workflow shell
|
||||||
|
- Shared module switch
|
||||||
|
- Shared section card
|
||||||
|
- Shared choice-pill selector
|
||||||
|
- Shared asset dropzone / preview block
|
||||||
|
- Variants and states:
|
||||||
|
- Active/inactive tabs
|
||||||
|
- Active/inactive pills
|
||||||
|
- Empty vs selected asset states
|
||||||
|
- Hover/focus for cards and action buttons
|
||||||
|
- Token/component ownership:
|
||||||
|
- Theme tokens in `src/styles/tokens.css`
|
||||||
|
- Global workflow styles in `src/styles/main.css`
|
||||||
|
- Shared components under `src/shared/components/ai-workflow/`
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- Target standard:
|
||||||
|
- Practical keyboard and screen-readable form semantics for internal desktop usage
|
||||||
|
- Keyboard/focus behavior:
|
||||||
|
- Visible focus ring on pills, buttons, upload areas, and tabs
|
||||||
|
- Contrast/readability:
|
||||||
|
- Maintain strong foreground/background contrast in dark mode
|
||||||
|
- Screen-reader semantics:
|
||||||
|
- Preserve labels for upload regions and tab panels
|
||||||
|
- Reduced motion and sensory considerations:
|
||||||
|
- No large-scale animation
|
||||||
|
|
||||||
|
## Responsive behavior
|
||||||
|
- Supported breakpoints/devices:
|
||||||
|
- Primary target desktop >= 1280px
|
||||||
|
- Secondary support tablet widths >= 768px
|
||||||
|
- Layout adaptations:
|
||||||
|
- Collapse multi-column workspace into two columns, then one column
|
||||||
|
- Preview area stops being sticky on narrow screens
|
||||||
|
- Touch/hover differences:
|
||||||
|
- Controls remain usable without hover-only affordances
|
||||||
|
|
||||||
|
## Interaction states
|
||||||
|
- Loading:
|
||||||
|
- Reserved for future backend integration
|
||||||
|
- Empty:
|
||||||
|
- Show upload placeholders and muted guidance text
|
||||||
|
- Error:
|
||||||
|
- Use Element Plus messaging if upload type or action constraints fail
|
||||||
|
- Success:
|
||||||
|
- Local draft actions can confirm save/export interactions
|
||||||
|
- Disabled:
|
||||||
|
- Non-active modules appear disabled but visible
|
||||||
|
- Offline/slow network, if applicable:
|
||||||
|
- Not applicable in the current local-only draft
|
||||||
|
|
||||||
|
## Content voice
|
||||||
|
- Tone:
|
||||||
|
- Direct and operational
|
||||||
|
- Terminology:
|
||||||
|
- Prefer commerce-video workflow language: 素材, 场景, 人脸, 话术, 产品, 输出
|
||||||
|
- Microcopy rules:
|
||||||
|
- Short labels
|
||||||
|
- Guidance belongs in helper text, not long paragraphs
|
||||||
|
|
||||||
|
## Implementation constraints
|
||||||
|
- Framework/styling system:
|
||||||
|
- Vue 3 + TypeScript + Element Plus
|
||||||
|
- Design-token constraints:
|
||||||
|
- Extend current CSS variables instead of introducing Tailwind
|
||||||
|
- Performance constraints:
|
||||||
|
- Keep the page static and local-first until API wiring is required
|
||||||
|
- Compatibility constraints:
|
||||||
|
- Must work within the existing Vite multi-page build
|
||||||
|
- Test/screenshot expectations:
|
||||||
|
- Build must pass
|
||||||
|
- Local dev server should render a screenshot-ready page at `image-video.html`
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] 图二和图三的字段是否需要完全一致,目前按“共用主流程 + 源素材差异化”实现
|
||||||
|
- [ ] 后续是否需要直接接入生成任务接口,目前仅实现本地工作台和交互草稿
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="top-bar">
|
<header class="top-bar" :class="{ 'top-bar--compact': !showNav && !showActions }">
|
||||||
<div class="logo-area">
|
<div class="logo-area" :class="{ 'logo-area--compact': !showNav && !showActions }">
|
||||||
<span class="app-name">数富AI-亚马逊</span>
|
<span class="app-name">数富AI-亚马逊</span>
|
||||||
<a href="/home" class="btn-home">返回首页</a>
|
<div v-if="$slots.logoExtra" class="logo-extra">
|
||||||
|
<slot name="logoExtra" />
|
||||||
|
</div>
|
||||||
|
<a v-if="showHomeLink" href="/home" class="btn-home">返回首页</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-sections" aria-label="顶部功能导航">
|
<nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航">
|
||||||
<section
|
<section
|
||||||
v-for="group in visibleNavGroups"
|
v-for="group in visibleNavGroups"
|
||||||
:key="group.columnKey"
|
:key="group.columnKey"
|
||||||
@@ -34,10 +37,10 @@
|
|||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="top-right">
|
<div v-if="showActions" class="top-right">
|
||||||
<BrandApiSecretSettingsButton />
|
<BrandApiSecretSettingsButton />
|
||||||
</div>
|
</div>
|
||||||
<DownloadProgressPanel />
|
<DownloadProgressPanel v-if="showActions" />
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ type ActiveNavKey =
|
|||||||
| 'patrol-delete'
|
| 'patrol-delete'
|
||||||
| 'query-asin'
|
| 'query-asin'
|
||||||
| 'collect-data'
|
| 'collect-data'
|
||||||
|
| 'image-video'
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
key: string
|
key: string
|
||||||
@@ -77,9 +81,15 @@ type NavGroup = {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
active: ActiveNavKey
|
active: ActiveNavKey
|
||||||
|
showNav?: boolean
|
||||||
|
showHomeLink?: boolean
|
||||||
|
showActions?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const active = props.active
|
const active = props.active
|
||||||
|
const showNav = props.showNav ?? true
|
||||||
|
const showHomeLink = props.showHomeLink ?? true
|
||||||
|
const showActions = props.showActions ?? true
|
||||||
const allowedColumnKeys = ref<string[] | null>(null)
|
const allowedColumnKeys = ref<string[] | null>(null)
|
||||||
|
|
||||||
const navGroups: ReadonlyArray<NavGroup> = [
|
const navGroups: ReadonlyArray<NavGroup> = [
|
||||||
@@ -88,6 +98,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
|||||||
label: '前端工具',
|
label: '前端工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
|
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
|
||||||
|
{ key: 'image-video', label: '视频', href: '/new_web_source/image-video.html' },
|
||||||
{ key: 'variant', label: '变体分析' },
|
{ key: 'variant', label: '变体分析' },
|
||||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
||||||
@@ -150,6 +161,14 @@ onMounted(async () => {
|
|||||||
border-bottom: 1px solid #2a2a2a;
|
border-bottom: 1px solid #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar--compact {
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-area {
|
.logo-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -158,6 +177,11 @@ onMounted(async () => {
|
|||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-area--compact {
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -173,6 +197,11 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-home:hover {
|
.btn-home:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -267,6 +296,12 @@ onMounted(async () => {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar--compact {
|
||||||
|
min-height: 64px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-area {
|
.logo-area {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div 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="/home">返回首页</a>
|
<a class="back-link" href="/home">返回首页</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="entrances" aria-label="图生视频入口">
|
<main class="entrances" aria-label="视频入口">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="entrance-btn"
|
class="entrance-btn"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
>
|
>
|
||||||
数字人
|
数字人
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="entrance-btn" @click="showSoon('带货视频')">
|
<button type="button" class="entrance-btn" @click="openDeliveryWorkspace">
|
||||||
带货视频
|
带货视频
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="entrance-btn" @click="showSoon('混剪')">
|
<button type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||||
@@ -26,12 +26,40 @@
|
|||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PageShell v-else theme="dark">
|
||||||
|
<div class="delivery-page">
|
||||||
|
<BrandTopBar
|
||||||
|
active="image-video"
|
||||||
|
:show-nav="false"
|
||||||
|
:show-home-link="false"
|
||||||
|
:show-actions="false"
|
||||||
|
>
|
||||||
|
<template #logoExtra>
|
||||||
|
<button type="button" class="delivery-page__top-return" @click="currentView = 'menu'">
|
||||||
|
返回二级菜单
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BrandTopBar>
|
||||||
|
|
||||||
|
<main class="delivery-page__body">
|
||||||
|
<DeliveryVideoWorkspace />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import PageShell from '@/components/layout/PageShell.vue'
|
||||||
|
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||||
|
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
|
|
||||||
|
type ViewMode = 'menu' | 'delivery'
|
||||||
|
|
||||||
|
const currentView = ref<ViewMode>('menu')
|
||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
const statusText = ref('')
|
const statusText = ref('')
|
||||||
const statusType = ref<'normal' | 'error'>('normal')
|
const statusType = ref<'normal' | 'error'>('normal')
|
||||||
@@ -52,6 +80,10 @@ function showSoon(name: string) {
|
|||||||
showStatus(`${name}暂未开通,敬请期待`)
|
showStatus(`${name}暂未开通,敬请期待`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeliveryWorkspace() {
|
||||||
|
currentView.value = 'delivery'
|
||||||
|
}
|
||||||
|
|
||||||
async function launchDesktop() {
|
async function launchDesktop() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.launch_yaoayanui) {
|
if (!api?.launch_yaoayanui) {
|
||||||
@@ -180,6 +212,32 @@ async function launchDesktop() {
|
|||||||
background: rgba(176, 42, 55, 0.92);
|
background: rgba(176, 42, 55, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delivery-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-page__body {
|
||||||
|
padding: 12px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-page__top-return {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #181818;
|
||||||
|
color: #d7d7d7;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-page__top-return:hover {
|
||||||
|
border-color: #b58e55;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.image-video-page {
|
.image-video-page {
|
||||||
padding: 88px 24px 40px;
|
padding: 88px 24px 40px;
|
||||||
@@ -194,5 +252,9 @@ async function launchDesktop() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delivery-page__body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,921 @@
|
|||||||
|
<template>
|
||||||
|
<section class="delivery-workspace">
|
||||||
|
<div class="delivery-workspace__tabs">
|
||||||
|
<el-tabs v-model="activeTab" class="delivery-workspace__tab-panel">
|
||||||
|
<el-tab-pane label="视频复刻" name="remake" />
|
||||||
|
<el-tab-pane label="图生视频" name="imageToVideo" />
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-grid">
|
||||||
|
<AiSectionCard
|
||||||
|
step="01"
|
||||||
|
title="源素材"
|
||||||
|
:description="currentTabCopy.sourceDescription"
|
||||||
|
class="delivery-card delivery-card--step-01"
|
||||||
|
>
|
||||||
|
<AiAssetDropzone
|
||||||
|
:title="currentTabCopy.primaryAssetTitle"
|
||||||
|
:description="currentTabCopy.primaryAssetDescription"
|
||||||
|
:placeholder="currentTabCopy.primaryAssetPlaceholder"
|
||||||
|
:helper="currentTabCopy.primaryAssetHelper"
|
||||||
|
:accept="currentTabCopy.primaryAssetAccept"
|
||||||
|
:file-name="currentWorkspace.primaryAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.primaryAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.primaryAsset.previewKind"
|
||||||
|
@select="(file) => setAsset(activeTab, 'primaryAsset', file)"
|
||||||
|
@clear="clearAsset(activeTab, 'primaryAsset')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AiAssetDropzone
|
||||||
|
:title="currentTabCopy.secondaryAssetTitle"
|
||||||
|
:description="currentTabCopy.secondaryAssetDescription"
|
||||||
|
:placeholder="currentTabCopy.secondaryAssetPlaceholder"
|
||||||
|
:helper="currentTabCopy.secondaryAssetHelper"
|
||||||
|
:accept="currentTabCopy.secondaryAssetAccept"
|
||||||
|
:file-name="currentWorkspace.secondaryAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.secondaryAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.secondaryAsset.previewKind"
|
||||||
|
@select="(file) => setAsset(activeTab, 'secondaryAsset', file)"
|
||||||
|
@clear="clearAsset(activeTab, 'secondaryAsset')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">素材推进方式</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.sourceStrategy"
|
||||||
|
:options="currentTabCopy.sourceStrategies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="02"
|
||||||
|
title="音视频生成"
|
||||||
|
description="先锁定投放规格,再决定整体风格、时长和画面清晰度。"
|
||||||
|
class="delivery-card delivery-card--step-02"
|
||||||
|
>
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">画面比例</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.ratio" :options="ratioOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">输出分辨率</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">视频时长</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.duration" :options="durationOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">整体风格</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.style" :options="styleOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">语言</span>
|
||||||
|
<el-select v-model="currentWorkspace.language" class="delivery-select">
|
||||||
|
<el-option
|
||||||
|
v-for="item in languageOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="03"
|
||||||
|
title="任务和预览"
|
||||||
|
description="先保存草稿,再开始组装带货视频。"
|
||||||
|
class="delivery-card delivery-card--preview delivery-card--step-03"
|
||||||
|
>
|
||||||
|
<div class="task-actions">
|
||||||
|
<el-button class="delivery-primary-btn" type="primary" @click="startAssembly">
|
||||||
|
开始组装
|
||||||
|
</el-button>
|
||||||
|
<div class="task-actions__row">
|
||||||
|
<button type="button" class="task-text-btn" @click="saveDraft">保存草稿</button>
|
||||||
|
<button type="button" class="task-text-btn" @click="exportConfig">导出配置</button>
|
||||||
|
</div>
|
||||||
|
<el-button class="delivery-secondary-btn" @click="buildStoryboard">
|
||||||
|
生成镜头方案
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-panel__meta">
|
||||||
|
<div class="preview-row">
|
||||||
|
<span class="preview-row__label">模式</span>
|
||||||
|
<span class="preview-row__value">{{ currentTabCopy.summaryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-row">
|
||||||
|
<span class="preview-row__label">规格</span>
|
||||||
|
<span class="preview-row__value">
|
||||||
|
{{ currentWorkspace.ratio }} / {{ currentWorkspace.resolution }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-row">
|
||||||
|
<span class="preview-row__label">口播</span>
|
||||||
|
<span class="preview-row__value">
|
||||||
|
{{ currentWorkspace.voiceTone }} / {{ currentWorkspace.language }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-row">
|
||||||
|
<span class="preview-row__label">产品类目</span>
|
||||||
|
<span class="preview-row__value">
|
||||||
|
{{ currentWorkspace.categories.join('、') || '未设置' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-panel__surface">
|
||||||
|
<img
|
||||||
|
v-if="currentWorkspace.primaryAsset.previewKind === 'image'"
|
||||||
|
:src="currentWorkspace.primaryAsset.previewUrl"
|
||||||
|
class="preview-panel__media"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="currentWorkspace.primaryAsset.previewKind === 'video'"
|
||||||
|
:src="currentWorkspace.primaryAsset.previewUrl"
|
||||||
|
class="preview-panel__media"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<div v-else class="preview-panel__placeholder">
|
||||||
|
上传主素材后,这里显示视频预览和最终交付摘要
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="04"
|
||||||
|
title="场景和镜头"
|
||||||
|
description="支持用提示词微调,也支持上传场景图直接替换原场景。"
|
||||||
|
class="delivery-card delivery-card--step-04"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">场景处理方式</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">场景提示词</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scenePrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:保留产品特写,换成室内直播间,镜头推进更快,背景偏暖光。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="场景图替换"
|
||||||
|
description="有明确参考图时直接上传,优先保证场景结构和光线。"
|
||||||
|
placeholder="上传场景参考图"
|
||||||
|
helper="支持 JPG / PNG"
|
||||||
|
accept="image/*"
|
||||||
|
:file-name="currentWorkspace.sceneAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.sceneAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||||
|
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
|
||||||
|
@clear="clearAsset(activeTab, 'sceneAsset')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">镜头节奏</span>
|
||||||
|
<el-slider v-model="currentWorkspace.cameraRhythm" :min="1" :max="10" />
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="05"
|
||||||
|
title="人脸和口播"
|
||||||
|
description="控制性别、人脸图、语气和语音来源。"
|
||||||
|
class="delivery-card delivery-card--step-05"
|
||||||
|
>
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">性别</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">语气</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.voiceTone" :options="voiceToneOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">语音来源</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.speechSource"
|
||||||
|
:options="speechSourceOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="模特图 / 人脸图"
|
||||||
|
description="上传清晰正脸图,适合用于数字人口播。"
|
||||||
|
placeholder="上传模特图"
|
||||||
|
helper="建议 1080px 以上,单人正面"
|
||||||
|
accept="image/*"
|
||||||
|
:file-name="currentWorkspace.avatarAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.avatarAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.avatarAsset.previewKind"
|
||||||
|
@select="(file) => setAsset(activeTab, 'avatarAsset', file)"
|
||||||
|
@clear="clearAsset(activeTab, 'avatarAsset')"
|
||||||
|
/>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="06"
|
||||||
|
title="话术和音乐"
|
||||||
|
description="支持直接口播、原话术微调,或者转成纯音乐版本。"
|
||||||
|
class="delivery-card delivery-card--step-06"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">话术模式</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.scriptMode" :options="scriptModeOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">话术内容</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scriptText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="输入要口播的话术,或粘贴需要整体微调的原始话术。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">微调约束</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scriptRules"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:保留原来的成交语气,不改卖点顺序,强调防滑、透气、现货。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="纯音乐素材"
|
||||||
|
description="需要做纯音乐版本时可上传,也可以后续从原视频中抽取。"
|
||||||
|
placeholder="上传背景音乐"
|
||||||
|
helper="支持 MP3 / WAV"
|
||||||
|
accept="audio/*"
|
||||||
|
:file-name="currentWorkspace.musicAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.musicAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.musicAsset.previewKind"
|
||||||
|
@select="(file) => setAsset(activeTab, 'musicAsset', file)"
|
||||||
|
@clear="clearAsset(activeTab, 'musicAsset')"
|
||||||
|
/>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="07"
|
||||||
|
title="标题标签关键词"
|
||||||
|
description="类目、平台和卖点一起配置,方便后续拆投放版本。"
|
||||||
|
class="delivery-card delivery-card--step-07"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">细分类目</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.categories"
|
||||||
|
:options="categoryOptions"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">投放平台</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.platforms"
|
||||||
|
:options="platformOptions"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">核心卖点</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.productFocus"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:上脚显瘦、脚感轻、百搭、直播间主推款。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="08"
|
||||||
|
title="执行说明"
|
||||||
|
description="补充收尾逻辑,例如保留人物动作,只替换场景和话术重点。"
|
||||||
|
class="delivery-card delivery-card--step-08"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">收口动作</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.cta"
|
||||||
|
placeholder="例如:结尾引导领券下单,强调今晚库存。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">执行说明</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.notes"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
resize="none"
|
||||||
|
placeholder="补充执行说明,例如:第二版转成纯音乐短视频。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import AiAssetDropzone from '@/shared/components/ai-workflow/AiAssetDropzone.vue'
|
||||||
|
import AiChoicePills from '@/shared/components/ai-workflow/AiChoicePills.vue'
|
||||||
|
import AiSectionCard from '@/shared/components/ai-workflow/AiSectionCard.vue'
|
||||||
|
|
||||||
|
type WorkspaceTab = 'remake' | 'imageToVideo'
|
||||||
|
type PreviewKind = 'image' | 'video' | 'file'
|
||||||
|
|
||||||
|
type AssetState = {
|
||||||
|
fileName: string
|
||||||
|
previewUrl: string
|
||||||
|
previewKind: PreviewKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssetKey =
|
||||||
|
| 'primaryAsset'
|
||||||
|
| 'secondaryAsset'
|
||||||
|
| 'sceneAsset'
|
||||||
|
| 'avatarAsset'
|
||||||
|
| 'musicAsset'
|
||||||
|
|
||||||
|
type WorkspaceState = {
|
||||||
|
primaryAsset: AssetState
|
||||||
|
secondaryAsset: AssetState
|
||||||
|
sceneAsset: AssetState
|
||||||
|
avatarAsset: AssetState
|
||||||
|
musicAsset: AssetState
|
||||||
|
sourceStrategy: string
|
||||||
|
ratio: string
|
||||||
|
resolution: string
|
||||||
|
duration: string
|
||||||
|
style: string
|
||||||
|
sceneMode: string
|
||||||
|
scenePrompt: string
|
||||||
|
cameraRhythm: number
|
||||||
|
gender: string
|
||||||
|
language: string
|
||||||
|
voiceTone: string
|
||||||
|
speechSource: string
|
||||||
|
scriptMode: string
|
||||||
|
scriptText: string
|
||||||
|
scriptRules: string
|
||||||
|
categories: string[]
|
||||||
|
platforms: string[]
|
||||||
|
productFocus: string
|
||||||
|
cta: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabCopy = {
|
||||||
|
summaryLabel: string
|
||||||
|
sourceDescription: string
|
||||||
|
primaryAssetTitle: string
|
||||||
|
primaryAssetDescription: string
|
||||||
|
primaryAssetPlaceholder: string
|
||||||
|
primaryAssetHelper: string
|
||||||
|
primaryAssetAccept: string
|
||||||
|
secondaryAssetTitle: string
|
||||||
|
secondaryAssetDescription: string
|
||||||
|
secondaryAssetPlaceholder: string
|
||||||
|
secondaryAssetHelper: string
|
||||||
|
secondaryAssetAccept: string
|
||||||
|
sourceStrategies: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref<WorkspaceTab>('remake')
|
||||||
|
|
||||||
|
const ratioOptions = ['9:16', '1:1', '16:9']
|
||||||
|
const resolutionOptions = ['720P', '1080P', '2K']
|
||||||
|
const durationOptions = ['35秒', '45秒', '60秒', '120秒']
|
||||||
|
const styleOptions = ['强转化', '直播感', '高级感', '高节奏切片']
|
||||||
|
const sceneModeOptions = ['提示词微调', '上传场景图替换']
|
||||||
|
const genderOptions = ['女', '男']
|
||||||
|
const voiceToneOptions = ['甜美', '萝莉', '御姐', '专业讲解', '原视频提取']
|
||||||
|
const languageOptions = ['国语', '英语', '粤语', '西班牙语']
|
||||||
|
const speechSourceOptions = ['文本直出', '从原视频提取语气', '从音频样本提取']
|
||||||
|
const scriptModeOptions = ['直接口播', '原话术微调', '纯音乐版本']
|
||||||
|
const categoryOptions = ['鞋子', '男装', '女装', '美妆', '家居', '零食', '数码']
|
||||||
|
const platformOptions = ['抖音', '快手', '视频号', '小红书']
|
||||||
|
|
||||||
|
const tabCopies: Record<WorkspaceTab, TabCopy> = {
|
||||||
|
remake: {
|
||||||
|
summaryLabel: '视频复刻',
|
||||||
|
sourceDescription: '上传原始带货视频后,可按原镜头节奏重组,也可以只保留人设或话术结构。',
|
||||||
|
primaryAssetTitle: '原始带货视频',
|
||||||
|
primaryAssetDescription: '主素材决定复刻的节奏、口播方式和镜头分布。',
|
||||||
|
primaryAssetPlaceholder: '上传需要复刻的视频',
|
||||||
|
primaryAssetHelper: '支持 MP4 / MOV,建议 35 秒到 2 分钟',
|
||||||
|
primaryAssetAccept: 'video/*',
|
||||||
|
secondaryAssetTitle: '补充素材',
|
||||||
|
secondaryAssetDescription: '可上传细节镜头、对比图或备用商品画面。',
|
||||||
|
secondaryAssetPlaceholder: '上传补充视频或商品图',
|
||||||
|
secondaryAssetHelper: '支持视频或图片,作为替补镜头使用',
|
||||||
|
secondaryAssetAccept: 'video/*,image/*',
|
||||||
|
sourceStrategies: ['保留镜头节奏', '只保留话术结构', '只保留人设风格'],
|
||||||
|
},
|
||||||
|
imageToVideo: {
|
||||||
|
summaryLabel: '图生视频',
|
||||||
|
sourceDescription: '从静态图起稿,补上场景和人物驱动逻辑,最终生成完整带货短视频。',
|
||||||
|
primaryAssetTitle: '产品主图',
|
||||||
|
primaryAssetDescription: '主图是整条视频的起点,决定产品主体和构图中心。',
|
||||||
|
primaryAssetPlaceholder: '上传产品主图',
|
||||||
|
primaryAssetHelper: '建议白底图或精修图,主体完整清晰',
|
||||||
|
primaryAssetAccept: 'image/*',
|
||||||
|
secondaryAssetTitle: '场景参考图',
|
||||||
|
secondaryAssetDescription: '需要指定拍摄环境时上传参考图,便于稳定光线和景别。',
|
||||||
|
secondaryAssetPlaceholder: '上传场景参考图',
|
||||||
|
secondaryAssetHelper: '支持 JPG / PNG',
|
||||||
|
secondaryAssetAccept: 'image/*',
|
||||||
|
sourceStrategies: ['静态图起稿', '主图+场景驱动', '主图+人像驱动'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssetState(): AssetState {
|
||||||
|
return {
|
||||||
|
fileName: '',
|
||||||
|
previewUrl: '',
|
||||||
|
previewKind: 'file',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaceState(): WorkspaceState {
|
||||||
|
return {
|
||||||
|
primaryAsset: createAssetState(),
|
||||||
|
secondaryAsset: createAssetState(),
|
||||||
|
sceneAsset: createAssetState(),
|
||||||
|
avatarAsset: createAssetState(),
|
||||||
|
musicAsset: createAssetState(),
|
||||||
|
sourceStrategy: '保留镜头节奏',
|
||||||
|
ratio: '9:16',
|
||||||
|
resolution: '1080P',
|
||||||
|
duration: '35秒',
|
||||||
|
style: '强转化',
|
||||||
|
sceneMode: '提示词微调',
|
||||||
|
scenePrompt: '',
|
||||||
|
cameraRhythm: 6,
|
||||||
|
gender: '女',
|
||||||
|
language: '国语',
|
||||||
|
voiceTone: '甜美',
|
||||||
|
speechSource: '文本直出',
|
||||||
|
scriptMode: '直接口播',
|
||||||
|
scriptText: '',
|
||||||
|
scriptRules: '',
|
||||||
|
categories: ['鞋子'],
|
||||||
|
platforms: ['抖音'],
|
||||||
|
productFocus: '',
|
||||||
|
cta: '',
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = reactive<Record<WorkspaceTab, WorkspaceState>>({
|
||||||
|
remake: createWorkspaceState(),
|
||||||
|
imageToVideo: {
|
||||||
|
...createWorkspaceState(),
|
||||||
|
sourceStrategy: '静态图起稿',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentWorkspace = computed(() => workspaces[activeTab.value])
|
||||||
|
const currentTabCopy = computed(() => tabCopies[activeTab.value])
|
||||||
|
|
||||||
|
const previewUrls = new Set<string>()
|
||||||
|
|
||||||
|
function resolvePreviewKind(file: File): PreviewKind {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
if (file.type.startsWith('video/')) {
|
||||||
|
return 'video'
|
||||||
|
}
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePreviewUrl(url: string) {
|
||||||
|
if (!url.startsWith('blob:')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
previewUrls.delete(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
|
||||||
|
const target = workspaces[tab][assetKey]
|
||||||
|
if (target.previewUrl) {
|
||||||
|
releasePreviewUrl(target.previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.fileName = file.name
|
||||||
|
target.previewKind = resolvePreviewKind(file)
|
||||||
|
target.previewUrl = target.previewKind === 'file' ? '' : URL.createObjectURL(file)
|
||||||
|
if (target.previewUrl) {
|
||||||
|
previewUrls.add(target.previewUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
|
||||||
|
const target = workspaces[tab][assetKey]
|
||||||
|
if (target.previewUrl) {
|
||||||
|
releasePreviewUrl(target.previewUrl)
|
||||||
|
}
|
||||||
|
target.fileName = ''
|
||||||
|
target.previewUrl = ''
|
||||||
|
target.previewKind = 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
ElMessage.success(`${currentTabCopy.value.summaryLabel}草稿已保存`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportConfig() {
|
||||||
|
ElMessage.success(`已导出${currentTabCopy.value.summaryLabel}配置`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoryboard() {
|
||||||
|
ElMessage.success(`已生成${currentTabCopy.value.summaryLabel}镜头方案草稿`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAssembly() {
|
||||||
|
ElMessage.success(`已加入${currentTabCopy.value.summaryLabel}组装队列`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
previewUrls.clear()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delivery-workspace {
|
||||||
|
min-width: 0;
|
||||||
|
--delivery-accent: #c59a5a;
|
||||||
|
--delivery-accent-strong: #e0bb82;
|
||||||
|
--delivery-accent-soft: rgba(197, 154, 90, 0.16);
|
||||||
|
--delivery-surface: #171717;
|
||||||
|
--delivery-surface-2: #1d1d1d;
|
||||||
|
--delivery-border: #323232;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-workspace__tabs {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-workspace__tab-panel {
|
||||||
|
--el-color-primary: var(--delivery-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.delivery-workspace__tab-panel .el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.delivery-workspace__tab-panel .el-tabs__nav-wrap::after) {
|
||||||
|
background: #313131;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.delivery-workspace__tab-panel .el-tabs__item) {
|
||||||
|
height: 40px;
|
||||||
|
color: #969696;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.delivery-workspace__tab-panel .el-tabs__item.is-active) {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.delivery-workspace__tab-panel .el-tabs__active-bar) {
|
||||||
|
background: var(--delivery-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-grid {
|
||||||
|
column-count: 4;
|
||||||
|
column-gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-card {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
-webkit-column-break-inside: avoid;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-form-grid--two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-field__label {
|
||||||
|
color: #f3f3f3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-text-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--delivery-accent-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-text-btn:hover {
|
||||||
|
color: #f0d0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--delivery-border);
|
||||||
|
background: var(--delivery-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row__label {
|
||||||
|
color: #969696;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row__value {
|
||||||
|
color: #f1f1f1;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel__surface {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 132px;
|
||||||
|
border: 1px solid var(--delivery-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #202020;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel__media {
|
||||||
|
width: 100%;
|
||||||
|
height: 132px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-card--preview .preview-panel__meta {
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-card--preview .task-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel__placeholder {
|
||||||
|
padding: 20px;
|
||||||
|
color: #7f7f7f;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card) {
|
||||||
|
height: 100%;
|
||||||
|
border-color: var(--delivery-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--delivery-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card__header) {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card__body) {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card__badge) {
|
||||||
|
background: linear-gradient(135deg, #7d6744 0%, #b18a54 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-choice-pills) {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-choice-pills__item) {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-color: #444;
|
||||||
|
background: #1a1a1a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-choice-pills__item:hover) {
|
||||||
|
border-color: #7b6a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-choice-pills__item--active) {
|
||||||
|
border-color: var(--delivery-accent);
|
||||||
|
background: var(--delivery-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone) {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__description) {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__upload .el-upload-dragger) {
|
||||||
|
min-height: 138px;
|
||||||
|
padding: 8px;
|
||||||
|
border-color: #434343;
|
||||||
|
background: #1d1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__upload .el-upload-dragger:hover) {
|
||||||
|
border-color: var(--delivery-accent);
|
||||||
|
background: #212121;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__preview),
|
||||||
|
:deep(.ai-asset-dropzone__empty) {
|
||||||
|
min-height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__media) {
|
||||||
|
height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__title) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__description),
|
||||||
|
:deep(.ai-asset-dropzone__helper),
|
||||||
|
:deep(.ai-asset-dropzone__filename) {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card__title) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-section-card__description) {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper),
|
||||||
|
:deep(.el-textarea__inner),
|
||||||
|
:deep(.el-select__wrapper) {
|
||||||
|
background: #121212;
|
||||||
|
box-shadow: 0 0 0 1px #414141 inset;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper.is-focus),
|
||||||
|
:deep(.el-textarea__inner:focus),
|
||||||
|
:deep(.el-select__wrapper.is-focused) {
|
||||||
|
box-shadow: 0 0 0 1px var(--delivery-accent) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner),
|
||||||
|
:deep(.el-select__selected-item),
|
||||||
|
:deep(.el-textarea__inner) {
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-textarea__inner::placeholder),
|
||||||
|
:deep(.el-input__inner::placeholder) {
|
||||||
|
color: #717171;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider__runway) {
|
||||||
|
background: #2c2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider__bar) {
|
||||||
|
background: linear-gradient(90deg, #8f6a3b, #d1ac71);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button.delivery-primary-btn) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
background: linear-gradient(90deg, #8f6a3b, #c49a58);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button.delivery-primary-btn:hover) {
|
||||||
|
background: linear-gradient(90deg, #7b5a31, #b78b49);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button.delivery-secondary-btn) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
background: #171717;
|
||||||
|
border-color: #4e4e4e;
|
||||||
|
color: #d8d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button.delivery-secondary-btn:hover) {
|
||||||
|
border-color: var(--delivery-accent);
|
||||||
|
color: #fff;
|
||||||
|
background: #1b1b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1800px) {
|
||||||
|
.delivery-grid {
|
||||||
|
column-count: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1380px) {
|
||||||
|
.delivery-grid {
|
||||||
|
column-count: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.delivery-grid {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-form-grid--two {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-asset-dropzone">
|
||||||
|
<div class="ai-asset-dropzone__head">
|
||||||
|
<div>
|
||||||
|
<div class="ai-asset-dropzone__title">{{ title }}</div>
|
||||||
|
<p v-if="description" class="ai-asset-dropzone__description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="fileName"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
class="ai-asset-dropzone__clear"
|
||||||
|
@click="$emit('clear')"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-upload
|
||||||
|
drag
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
:accept="accept"
|
||||||
|
class="ai-asset-dropzone__upload"
|
||||||
|
@change="handleChange"
|
||||||
|
>
|
||||||
|
<div v-if="previewUrl || (fileName && previewKind === 'file')" class="ai-asset-dropzone__preview">
|
||||||
|
<img
|
||||||
|
v-if="previewKind === 'image'"
|
||||||
|
:src="previewUrl"
|
||||||
|
class="ai-asset-dropzone__media"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="previewKind === 'video'"
|
||||||
|
:src="previewUrl"
|
||||||
|
class="ai-asset-dropzone__media"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<div v-else class="ai-asset-dropzone__file">
|
||||||
|
<span>{{ fileName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ai-asset-dropzone__empty">
|
||||||
|
<div class="ai-asset-dropzone__placeholder">{{ placeholder }}</div>
|
||||||
|
<div v-if="helper" class="ai-asset-dropzone__helper">{{ helper }}</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<div v-if="fileName" class="ai-asset-dropzone__footer">
|
||||||
|
<span class="ai-asset-dropzone__filename">{{ fileName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadFile, UploadFiles } from 'element-plus'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
placeholder?: string
|
||||||
|
helper?: string
|
||||||
|
accept?: string
|
||||||
|
fileName?: string
|
||||||
|
previewUrl?: string
|
||||||
|
previewKind?: 'image' | 'video' | 'file'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
description: '',
|
||||||
|
placeholder: '点击或拖拽上传素材',
|
||||||
|
helper: '',
|
||||||
|
accept: '',
|
||||||
|
fileName: '',
|
||||||
|
previewUrl: '',
|
||||||
|
previewKind: 'file',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'select', file: File): void
|
||||||
|
(event: 'clear'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||||
|
const rawFile = uploadFile.raw
|
||||||
|
if (!rawFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('select', rawFile)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-asset-dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__title {
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__description {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__upload .el-upload) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__upload .el-upload-dragger) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 176px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed #474747;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ai-asset-dropzone__upload .el-upload-dragger:hover) {
|
||||||
|
border-color: #8c63ff;
|
||||||
|
background: #232323;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__preview,
|
||||||
|
.ai-asset-dropzone__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 150px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__empty {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__placeholder {
|
||||||
|
color: #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__helper {
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__media {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__file {
|
||||||
|
color: #d8d8d8;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__footer {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
border: 1px solid #383838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__filename {
|
||||||
|
color: #d8d8d8;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
frontend-vue/src/shared/components/ai-workflow/AiChoicePills.vue
Normal file
100
frontend-vue/src/shared/components/ai-workflow/AiChoicePills.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-choice-pills" :class="{ 'ai-choice-pills--wrap': wrap }">
|
||||||
|
<button
|
||||||
|
v-for="option in normalizedOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="ai-choice-pills__item"
|
||||||
|
:class="{ 'ai-choice-pills__item--active': isActive(option.value) }"
|
||||||
|
@click="toggle(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
type Option = string | { label: string; value: string }
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string | string[]
|
||||||
|
options: Option[]
|
||||||
|
multiple?: boolean
|
||||||
|
wrap?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
multiple: false,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string | string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const normalizedOptions = computed(() =>
|
||||||
|
props.options.map((option) =>
|
||||||
|
typeof option === 'string' ? { label: option, value: option } : option,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isActive(value: string) {
|
||||||
|
return props.multiple
|
||||||
|
? Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
||||||
|
: props.modelValue === value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
if (!props.multiple) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
|
const index = current.indexOf(value)
|
||||||
|
if (index >= 0) {
|
||||||
|
current.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
current.push(value)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-choice-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-choice-pills--wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-choice-pills__item {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid #4b4b4b;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1b1b1b;
|
||||||
|
color: #dddddd;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-choice-pills__item:hover {
|
||||||
|
border-color: #8a8a8a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-choice-pills__item--active {
|
||||||
|
border-color: #8c63ff;
|
||||||
|
background: rgba(140, 99, 255, 0.16);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-module-switch" role="tablist" aria-label="功能模块切换">
|
||||||
|
<button
|
||||||
|
v-for="item in options"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
class="ai-module-switch__item"
|
||||||
|
:class="{
|
||||||
|
'ai-module-switch__item--active': modelValue === item.value,
|
||||||
|
'ai-module-switch__item--disabled': item.disabled,
|
||||||
|
}"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="$emit('update:modelValue', item.value)"
|
||||||
|
>
|
||||||
|
<span class="ai-module-switch__label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.description" class="ai-module-switch__description">
|
||||||
|
{{ item.description }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export type AiModuleSwitchOption = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
description?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
options: AiModuleSwitchOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-module-switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.65);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__item:hover:not(:disabled) {
|
||||||
|
border-color: rgba(96, 165, 250, 0.7);
|
||||||
|
box-shadow: 0 14px 24px rgba(8, 15, 31, 0.34);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__item--active {
|
||||||
|
border-color: rgba(96, 165, 250, 0.82);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.96));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(59, 130, 246, 0.18),
|
||||||
|
0 18px 28px rgba(8, 15, 31, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__item--disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__label {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-module-switch__description {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ai-module-switch {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
frontend-vue/src/shared/components/ai-workflow/AiSectionCard.vue
Normal file
101
frontend-vue/src/shared/components/ai-workflow/AiSectionCard.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<section class="ai-section-card">
|
||||||
|
<header class="ai-section-card__header">
|
||||||
|
<div class="ai-section-card__title-wrap">
|
||||||
|
<span v-if="step" class="ai-section-card__badge">{{ step }}</span>
|
||||||
|
<div class="ai-section-card__title-group">
|
||||||
|
<h3 class="ai-section-card__title">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="ai-section-card__description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.extra" class="ai-section-card__extra">
|
||||||
|
<slot name="extra" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="ai-section-card__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
step?: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-section-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #343434;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #171717;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #6a5cff 0%, #4f7bff 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__title-group {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__title {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__description {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__extra {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-section-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-workflow-shell">
|
||||||
|
<div class="ai-workflow-shell__grid" aria-hidden="true"></div>
|
||||||
|
<div class="ai-workflow-shell__inner">
|
||||||
|
<header class="ai-workflow-shell__header">
|
||||||
|
<div class="ai-workflow-shell__meta">
|
||||||
|
<slot name="meta" />
|
||||||
|
</div>
|
||||||
|
<div class="ai-workflow-shell__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="ai-workflow-shell__toolbar">
|
||||||
|
<slot name="toolbar" />
|
||||||
|
</div>
|
||||||
|
<main class="ai-workflow-shell__content">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-workflow-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top center, rgba(59, 130, 246, 0.14), transparent 32%),
|
||||||
|
radial-gradient(circle at right bottom, rgba(99, 102, 241, 0.12), transparent 24%),
|
||||||
|
linear-gradient(180deg, #060913 0%, #09111d 40%, #070b14 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(77, 104, 145, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(77, 104, 145, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.9), transparent 75%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__meta,
|
||||||
|
.ai-workflow-shell__actions {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__toolbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.ai-workflow-shell__inner {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-workflow-shell__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--color-bg-light);
|
background: var(--color-bg-light);
|
||||||
|
font-family: var(--font-family-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -49,3 +50,14 @@ html {
|
|||||||
background: var(--color-bg-dark);
|
background: var(--color-bg-dark);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-dark a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .el-tabs__item:focus-visible,
|
||||||
|
.theme-dark button:focus-visible,
|
||||||
|
.theme-dark a:focus-visible {
|
||||||
|
outline: 2px solid rgba(96, 165, 250, 0.9);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
--font-family-base: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
--font-family-base: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||||
--color-primary: #409eff;
|
--color-primary: #409eff;
|
||||||
--color-bg-light: #eef3f8;
|
--color-bg-light: #eef3f8;
|
||||||
--color-bg-dark: #0d0d0d;
|
--color-bg-dark: #060913;
|
||||||
--color-surface-light: rgba(255, 255, 255, 0.92);
|
--color-surface-light: rgba(255, 255, 255, 0.92);
|
||||||
--color-surface-dark: #1a1a1a;
|
--color-surface-dark: #0f172a;
|
||||||
--color-border-light: #d6e4ef;
|
--color-border-light: #d6e4ef;
|
||||||
--color-border-dark: #2a2a2a;
|
--color-border-dark: #334155;
|
||||||
--color-text-primary: #303133;
|
--color-text-primary: #303133;
|
||||||
--color-text-secondary: #606266;
|
--color-text-secondary: #606266;
|
||||||
--color-text-dark: #e5eaf3;
|
--color-text-dark: #e5eaf3;
|
||||||
|
--color-text-muted-dark: #94a3b8;
|
||||||
|
--color-accent-blue: #60a5fa;
|
||||||
|
--color-accent-indigo: #818cf8;
|
||||||
|
--color-accent-surface: rgba(37, 99, 235, 0.14);
|
||||||
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.08);
|
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-card-dark: 0 18px 38px rgba(2, 8, 23, 0.35);
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
|
|||||||
1
frontend-vue/vite-image-video.err.log
Normal file
1
frontend-vue/vite-image-video.err.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[2m16:09:19[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m Pre-transform error: Failed to load url /src/main.ts (resolved id: /src/main.ts). Does the file exist?
|
||||||
36
frontend-vue/vite-image-video.out.log
Normal file
36
frontend-vue/vite-image-video.out.log
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
> crawler-plugin-frontend-vue@0.0.1 dev
|
||||||
|
> vite --host --port 5173
|
||||||
|
|
||||||
|
|
||||||
|
[32m[1mVITE[22m v7.3.1[39m [2mready in [0m[1m861[22m[2m[0m ms[22m
|
||||||
|
|
||||||
|
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
||||||
|
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.31.112:[1m5173[22m/[39m
|
||||||
|
[2m14:18:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ new dependencies optimized: [33melement-plus/es, element-plus/es/components/base/style/css, element-plus/es/components/select/style/css, element-plus/es/components/option/style/css, element-plus/es/components/slider/style/css, element-plus/es/components/input/style/css, element-plus/es/components/button/style/css, element-plus/es/components/tabs/style/css, element-plus/es/components/tab-pane/style/css, element-plus/es/components/upload/style/css[32m[39m
|
||||||
|
[2m14:18:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ optimized dependencies changed. reloading[39m
|
||||||
|
[2m14:47:00[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m15:05:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m15:16:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css[22m
|
||||||
|
[2m15:16:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiChoicePills.vue?vue&type=style&index=0&scoped=74573572&lang.css[22m
|
||||||
|
[2m15:17:34[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiAssetDropzone.vue?vue&type=style&index=0&scoped=1625dec6&lang.css[22m
|
||||||
|
[2m15:22:09[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m15:23:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m15:23:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ new dependencies optimized: [33melement-plus/es/components/dialog/style/css[32m[39m
|
||||||
|
[2m15:23:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ optimized dependencies changed. reloading[39m
|
||||||
|
[2m15:31:28[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue[22m
|
||||||
|
[2m15:31:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m15:52:41[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:02:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css[22m
|
||||||
|
[2m16:03:03[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m16:05:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:11:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:11:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css[22m
|
||||||
|
[2m16:16:44[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css[22m
|
||||||
|
[2m16:18:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue[22m
|
||||||
|
[2m16:19:34[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:19:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
||||||
|
[2m16:33:19[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:34:33[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:34:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
|
[2m16:49:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
||||||
Reference in New Issue
Block a user