修复菜单消失

This commit is contained in:
super
2026-06-10 17:52:58 +08:00
parent c3d43aa26a
commit c9efdf8b0e
6 changed files with 193 additions and 26 deletions

View File

@@ -38,4 +38,8 @@ public class CollectDataSubmitResultRequest {
@JsonAlias({"rows", "data", "items"})
@Schema(description = "本分片回传数据行")
private List<CollectDataSubmitRowDto> items = new ArrayList<>();
@JsonAlias({"summary", "summary_rows", "汇总"})
@Schema(description = "Python 端按关键词聚合的配送方式 / 页数统计;通常仅在 done=true 时携带空数组等同于不携带Java 回退到自聚合 rawItems。")
private List<CollectDataSummaryRowDto> summaries = new ArrayList<>();
}

View File

@@ -0,0 +1,54 @@
package com.nanri.aiimage.modules.collectdata.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "采集数据 Python 关键词级聚合统计行,通常仅在 done=true 时携带")
public class CollectDataSummaryRowDto {
/**
* 关键词原文。
*/
@JsonAlias({"关键词", "key_word", "key word"})
@Schema(description = "关键词原文", example = "phone case")
private String keyword;
/**
* 该关键词命中 FBA 配送方式的行数。
*/
@Schema(description = "FBA 行数", example = "12")
private Integer fba;
/**
* 该关键词命中 FBM 配送方式的行数。
*/
@Schema(description = "FBM 行数", example = "8")
private Integer fbm;
/**
* 该关键词命中 AMZ 配送方式的行数。
*/
@Schema(description = "AMZ 行数", example = "5")
private Integer amz;
/**
* 该关键词「无配送方式」(未识别)行数。
*/
@JsonProperty("none_count")
@JsonAlias({"none", "unknown", "无配送方式", "noneCount"})
@Schema(description = "无配送方式(未识别 FBA/FBM/AMZ行数", example = "3")
private Integer noneCount;
/**
* 该关键词所有命中行的最大页码,由 Python 抓取端汇总。
*/
@JsonProperty("total_page")
@JsonAlias({"page", "totalPage", "页数", "max_page", "maxPage"})
@Schema(description = "该关键词总页数Python 端取所有命中行的最大页码)", example = "10")
private Integer totalPage;
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.collectdata.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
@@ -30,14 +31,18 @@ public class CollectDataExcelAssemblyService {
*
* @param outputXlsx 目标文件
* @param items 经过 ASIN 去重 / 无效品牌过滤 / 品牌检测后保留下来的明细行写入「采集数据结果」sheet
* @param rawItems Python 回传的全量原始行(不经后端任何过滤),写入「结果文件」sheet 的关键词聚合统计
* @param summaries Python 在 done=true 时携带的关键词级聚合统计;非空时优先使用,直接落「结果文件」sheet
* @param rawItems Python 回传的全量原始行(不经后端任何过滤);仅在 summaries 为空时作为 fallback 自聚合
*/
public void writeWorkbook(File outputXlsx, List<CollectDataResultRowVo> items, List<CollectDataResultRowVo> rawItems) {
public void writeWorkbook(File outputXlsx,
List<CollectDataResultRowVo> items,
List<CollectDataSummaryRowDto> summaries,
List<CollectDataResultRowVo> rawItems) {
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true);
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
writeDetailSheet(workbook, items);
writeSummarySheet(workbook, rawItems);
writeSummarySheet(workbook, summaries, rawItems);
workbook.write(outputStream);
} catch (Exception ex) {
log.warn("[collect-data] write workbook failed: {}", ex.getMessage());
@@ -76,11 +81,14 @@ public class CollectDataExcelAssemblyService {
}
/**
* 「结果文件」sheet 基于 Python 回传的全量原始数据按关键词聚合:
* - FBA / FBM / AMZ / 无配送方式 列为各分类的命中行数
* - 页数列取该关键词所有原始行中页码的最大值,反映 Python 实际抓取到的总页数。
* 「结果文件」sheet 关键词聚合:
* - 优先使用 Python 在 done=true 时携带的 summaries避免 Java 端二次聚合带来的口径漂移
* - 当 summaries 为空Python 端尚未接入或解析失败)时,回退到基于 rawItems 自聚合,
* 保证 Excel 不会因迁移过渡期而出现空表。等 Python 端全部接入并稳定后,可移除 fallback 分支。
*/
private void writeSummarySheet(SXSSFWorkbook workbook, List<CollectDataResultRowVo> rawItems) {
private void writeSummarySheet(SXSSFWorkbook workbook,
List<CollectDataSummaryRowDto> summaries,
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++) {
@@ -88,6 +96,30 @@ public class CollectDataExcelAssemblyService {
sheet.setColumnWidth(i, (i == 0 ? 28 : 12) * 256);
}
if (summaries != null && !summaries.isEmpty()) {
// 优先分支Python 携带的关键词级聚合直接落表null 字段统一兜底为 0页数 ≤0 写空字符串。
int rowIndex = 1;
for (CollectDataSummaryRowDto item : summaries) {
if (item == null) {
continue;
}
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(safe(item.getKeyword()));
row.createCell(1).setCellValue(intOrZero(item.getFba()));
row.createCell(2).setCellValue(intOrZero(item.getFbm()));
row.createCell(3).setCellValue(intOrZero(item.getAmz()));
row.createCell(4).setCellValue(intOrZero(item.getNoneCount()));
Integer totalPage = item.getTotalPage();
if (totalPage != null && totalPage > 0) {
row.createCell(5).setCellValue(totalPage);
} else {
row.createCell(5).setCellValue("");
}
}
return;
}
// Fallback 分支:基于 rawItems 自聚合Python 端未接入时使用)。
Map<String, KeywordSummary> grouped = new LinkedHashMap<>();
if (rawItems != null) {
for (CollectDataResultRowVo item : rawItems) {
@@ -131,6 +163,10 @@ public class CollectDataExcelAssemblyService {
return value == null ? "" : value;
}
private int intOrZero(Integer value) {
return value == null ? 0 : value;
}
private static class KeywordSummary {
int fba;
int fbm;

View File

@@ -18,6 +18,7 @@ import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSourceFileDto;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitRowDto;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
@@ -454,6 +455,11 @@ public class CollectDataService {
persistScope(taskId, scopeKey, scopeHash, chunkTotal, request);
stats.finalRowCount = countFinalRows(taskId);
// Python 在 done=true 那次回传携带关键词级聚合统计;非空时按"最后一次为准"覆盖。
List<CollectDataSummaryRowDto> incomingSummaries = request.getSummaries();
if (incomingSummaries != null && !incomingSummaries.isEmpty()) {
stats.summaries = new ArrayList<>(incomingSummaries);
}
persistStats(task, stats);
if (request.getError() != null && !request.getError().isBlank()) {
@@ -812,6 +818,8 @@ public class CollectDataService {
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("结果记录不存在");
}
// 先加载 stats使 Python 携带的 summaries 可优先用于「结果文件」sheetrawRows 仅作为 fallback。
CollectDataStats stats = loadStats(task);
List<CollectDataResultRowVo> rows = loadFinalRows(task.getId());
// Sheet「结果文件」按需求基于 Python 回传的全量数据聚合,不经后端 ASIN/品牌过滤丢弃,
// 因此从 biz_task_chunk 反序列化全部原始行。
@@ -820,7 +828,7 @@ public class CollectDataService {
String filename = buildResultFilename(task, result);
File xlsx = FileUtil.file(workRoot, filename);
try {
excelAssemblyService.writeWorkbook(xlsx, rows, rawRows);
excelAssemblyService.writeWorkbook(xlsx, rows, stats.summaries, rawRows);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
result.setResultFilename(filename);
result.setResultFileUrl(objectKey);
@@ -831,7 +839,7 @@ public class CollectDataService {
result.setErrorMessage(null);
fileResultMapper.updateById(result);
CollectDataStats stats = loadStats(task);
// 复用同一份 stats 更新 finalRowCount 后再持久化,避免重复 loadStats 丢失 summaries。
stats.finalRowCount = rows.size();
persistStats(task, stats);
task.setStatus(STATUS_SUCCESS);
@@ -998,6 +1006,16 @@ public class CollectDataService {
stats.brandRejectedCount = root.path("brandRejectedCount").asInt(0);
stats.brandQueryFailedCount = root.path("brandQueryFailedCount").asInt(0);
stats.finalRowCount = root.path("finalRowCount").asInt(0);
// 反序列化 Python 携带的关键词级聚合;解析失败保持空 list仅 warn 不抛,避免影响其他统计
JsonNode summariesNode = root.get("summaries");
if (summariesNode != null && summariesNode.isArray()) {
try {
stats.summaries = objectMapper.convertValue(summariesNode,
new TypeReference<List<CollectDataSummaryRowDto>>() {});
} catch (Exception convertEx) {
log.warn("[collect-data] parse summaries failed taskId={} err={}", task.getId(), convertEx.getMessage());
}
}
} catch (Exception ex) {
log.warn("[collect-data] parse result stats failed taskId={} err={}", task.getId(), ex.getMessage());
}
@@ -1017,6 +1035,8 @@ public class CollectDataService {
payload.put("brandRejectedCount", stats.brandRejectedCount);
payload.put("brandQueryFailedCount", stats.brandQueryFailedCount);
payload.put("finalRowCount", stats.finalRowCount);
// 持久化 Python 携带的关键词级聚合;后续 processResultFileJob 阶段读取用于落 Excel
payload.put("summaries", stats.summaries == null ? List.of() : stats.summaries);
task.setResultJson(objectMapper.writeValueAsString(payload));
} catch (Exception ex) {
throw new BusinessException("保存采集统计失败");
@@ -1129,6 +1149,8 @@ public class CollectDataService {
private int brandRejectedCount;
private int brandQueryFailedCount;
private int finalRowCount;
// Python 在 done=true 时携带的关键词级聚合统计;空表示由 Java 端用 rawItems 自聚合兜底
private List<CollectDataSummaryRowDto> summaries = new ArrayList<>();
}
@Transactional

View File

@@ -71,6 +71,8 @@ type NavItem = {
key: string
label: string
href?: string
columnKey?: string
aliases?: ReadonlyArray<string>
}
type NavGroup = {
@@ -79,17 +81,21 @@ type NavGroup = {
items: ReadonlyArray<NavItem>
}
const props = defineProps<{
const props = withDefaults(defineProps<{
active: ActiveNavKey
showNav?: boolean
showHomeLink?: boolean
showActions?: boolean
}>()
}>(), {
showNav: true,
showHomeLink: true,
showActions: true,
})
const active = props.active
const showNav = props.showNav ?? true
const showHomeLink = props.showHomeLink ?? true
const showActions = props.showActions ?? true
const showNav = props.showNav
const showHomeLink = props.showHomeLink
const showActions = props.showActions
const allowedColumnKeys = ref<string[] | null>(null)
const navGroups: ReadonlyArray<NavGroup> = [
@@ -115,7 +121,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
{ key: 'withdraw', label: '取款' },
@@ -132,12 +138,29 @@ const navGroups: ReadonlyArray<NavGroup> = [
},
]
function getItemPermissionKeys(item: NavItem) {
return [item.key, item.columnKey, ...(item.aliases || [])]
.map((key) => String(key || '').trim().toLowerCase())
.filter(Boolean)
}
const visibleNavGroups = computed(() => {
if (allowedColumnKeys.value === null) {
return navGroups
}
const allowedSet = new Set(allowedColumnKeys.value)
return navGroups.filter((group) => allowedSet.has(group.columnKey))
const groups = navGroups.flatMap((group) => {
if (allowedSet.has(group.columnKey)) {
return [group]
}
const items = group.items.filter((item) =>
getItemPermissionKeys(item).some((key) => allowedSet.has(key)),
)
return items.length ? [{ ...group, items }] : []
})
return groups.length ? groups : navGroups
})
onMounted(async () => {

View File

@@ -4,7 +4,9 @@ export interface PermissionMenuItem {
id: number | string
name?: string
column_key?: string
columnKey?: string
route_path?: string
routePath?: string
menu_type?: string
sort_order?: number
created_at?: string
@@ -12,8 +14,10 @@ export interface PermissionMenuItem {
interface PermissionMenuResponse {
success: boolean
data?: PermissionMenuItem[]
items?: PermissionMenuItem[]
error?: string
message?: string
}
function getCurrentUserId() {
@@ -29,6 +33,23 @@ function getAppPermissionCacheKey(uid: number) {
return `app_column_permissions:${String(uid)}`
}
function getAuthToken() {
return typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || ''
}
function normalizeColumnKeys(items: PermissionMenuItem[] | undefined) {
const keys = new Set<string>()
for (const item of items || []) {
for (const value of [item.column_key, item.columnKey, item.route_path, item.routePath]) {
const key = String(value || '').trim().toLowerCase()
if (key) {
keys.add(key)
}
}
}
return Array.from(keys)
}
export async function getCurrentUserAppColumnKeys() {
const uid = getCurrentUserId()
const cacheKey = getAppPermissionCacheKey(uid)
@@ -36,28 +57,35 @@ export async function getCurrentUserAppColumnKeys() {
try {
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
if (Array.isArray(cachedItems)) {
return cachedItems
.map((item) => String(item.column_key || '').trim().toLowerCase())
.filter(Boolean)
const cachedKeys = normalizeColumnKeys(cachedItems)
if (cachedKeys.length) {
return cachedKeys
}
}
} catch (_error) {}
const headers: Record<string, string> = {}
const token = getAuthToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
const res = await requestGetJson<PermissionMenuResponse>(
`/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`,
`/newApi/api/admin/permission-users/${encodeURIComponent(String(uid))}/column-permissions`,
{
params: { menu_type: 'app' },
params: { menuType: 'app' },
headers,
},
)
if (!res.success) {
throw new Error(res.error || '获取菜单权限失败')
throw new Error(res.error || res.message || '获取菜单权限失败')
}
const items = res.data || res.items || []
try {
window.localStorage.setItem(cacheKey, JSON.stringify(res.items || []))
window.localStorage.setItem(cacheKey, JSON.stringify(items))
} catch (_error) {}
return (res.items || [])
.map((item) => String(item.column_key || '').trim().toLowerCase())
.filter(Boolean)
return normalizeColumnKeys(items)
}