task-284(收尾/在途批次): 页头按钮归位卡内toolbar+店铺数据记录改名+弹窗报错清除; Java page_size配对/按角色数据范围过滤/撞款导出XLSX/Excel图改WPS DISPIMG/V110查询索引

This commit is contained in:
2026-09-06 20:17:14 +08:00
parent 898fae4cb3
commit 26e062d32c
24 changed files with 269 additions and 153 deletions
@@ -80,7 +80,7 @@ async function create(): Promise<void> {
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名" :error="errors.username">
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" />
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" @input="errors.username = undefined" />
</el-form-item>
<el-form-item label="密码" :error="errors.password">
<el-input
@@ -89,6 +89,7 @@ async function create(): Promise<void> {
show-password
autocomplete="new-password"
placeholder="密码至少 6 个字符"
@input="errors.password = undefined"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
@@ -87,6 +87,7 @@ async function save(): Promise<void> {
show-password
autocomplete="new-password"
placeholder="留空则不修改密码"
@input="errors.password = undefined"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
@@ -299,10 +299,6 @@ onMounted(loadTree)
<h2>商品类目</h2>
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p>
</div>
<div class="actions">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
@@ -314,6 +310,14 @@ onMounted(loadTree)
</el-card>
<el-card v-if="!searching" shadow="never">
<div class="table-toolbar">
<div class="tb-buttons">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
</div>
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
<el-table-column label="类目名称" min-width="320">
<template #default="{ row }">
@@ -379,18 +383,15 @@ onMounted(loadTree)
</template>
</el-table-column>
</el-table>
<div class="tree-actions">
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
<span class="btn-group">
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
</span>
</div>
</el-card>
<el-card v-else shadow="never">
<div class="search-head">
<span>搜索结果 {{ searchTotal.toLocaleString() }} </span>
<span class="dim">{{ keyword }}</span>
<div class="table-toolbar">
<div class="tb-buttons">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
<span class="dim">搜索结果 {{ searchTotal.toLocaleString() }} {{ keyword ? ` · ${keyword}` : '' }}</span>
</div>
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
<el-table-column prop="name" label="类目名称" min-width="180" />
@@ -449,13 +450,10 @@ onMounted(loadTree)
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.tb-buttons { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-row { display: flex; gap: 10px; align-items: center; }
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.btn-group { display: inline-flex; gap: 6px; }
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
.load-more-row { text-align: center; margin-top: 12px; }
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/] 折叠标记 + 粗体名称。 */
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
@@ -25,7 +25,7 @@ export async function searchProductCategories(
page: number,
pageSize: number,
): Promise<ProductCategorySearchPage> {
const params: Record<string, string | number> = { page, pageSize }
const params: Record<string, string | number> = { page, page_size: pageSize }
const trimmed = keyword.trim()
if (trimmed) params.keyword = trimmed
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
@@ -182,12 +182,12 @@ onMounted(load)
<h2>数字人版本管理</h2>
<p>管理数字人客户端的草稿/已发布/已废弃版本</p>
</div>
<div class="actions">
<el-button type="primary" @click="uploadVisible = true">上传草稿状态</el-button>
</div>
</div>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="uploadVisible = true">上传草稿状态</el-button>
</div>
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
<el-table-column prop="version" label="版本号" min-width="150" />
<el-table-column label="状态" min-width="100">
@@ -271,7 +271,6 @@ onMounted(load)
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.star { color: #e6a23c; font-weight: 700; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
@@ -138,9 +138,6 @@ onMounted(() => {
<h2>生成记录</h2>
<p>查看各用户在后台生成的历史记录与结果图</p>
</div>
<div class="heading-actions">
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
</div>
</div>
<el-card shadow="never" class="filter-card">
@@ -180,6 +177,9 @@ onMounted(() => {
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
</div>
<el-alert
v-if="errorText"
:title="errorText"
@@ -93,12 +93,12 @@ onMounted(load)
<h2>软件版本管理</h2>
<p>管理客户端更新包版本用于 /api/version/latest 下发</p>
</div>
<div class="actions">
<el-button type="primary" @click="openUpload">上传新版本</el-button>
</div>
</div>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openUpload">上传新版本</el-button>
</div>
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
<el-table-column prop="version" label="版本号" min-width="180" />
<el-table-column label="下载链接" min-width="240">
@@ -147,7 +147,6 @@ onMounted(load)
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
@@ -239,13 +239,6 @@ onMounted(load)
<h2>视频任务记录</h2>
<p>查看图生视频任务卡片在线预览与下载视频配置数据范围授权</p>
</div>
<div class="actions">
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
@@ -265,16 +258,15 @@ onMounted(load)
</div>
</el-card>
<div v-loading="loading" class="video-summary-row">
<span> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalTasks"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
/>
<div v-loading="loading" class="list-toolbar">
<div class="lt-buttons">
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
<span class="list-toolbar-summary"> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
</div>
<div v-if="tasks.length" class="task-card-grid">
@@ -324,6 +316,16 @@ onMounted(load)
</div>
<el-empty v-else-if="!loading" description="暂无视频任务记录" />
<div class="table-footer">
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalTasks"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
/>
</div>
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
<div v-loading="detailLoading">
@@ -380,6 +382,10 @@ onMounted(load)
.f-item.wide { width: 340px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; margin-left: auto; flex: none; }
.video-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
.list-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--el-text-color-secondary); font-size: 13px; margin-bottom: 14px; }
.lt-buttons { display: inline-flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.list-toolbar-summary { white-space: nowrap; }
.table-footer { display: flex; justify-content: flex-end; margin-top: 14px; }
.task-card-grid { display: flex; flex-direction: column; gap: 14px; }
.task-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
@@ -74,7 +74,7 @@ async function load() {
totalShops.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败')
ElMessage.error(error instanceof Error ? error.message : '店铺数据记录加载失败')
} finally {
loading.value = false
}
@@ -230,22 +230,9 @@ onMounted(load)
<div class="page-stack">
<div class="page-heading">
<div>
<h2>店铺数据任务/记录</h2>
<h2>店铺数据记录</h2>
<p>按店铺查看数据抓取结果文件支持筛选下载删除与数据范围授权</p>
</div>
<div class="actions">
<el-checkbox
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
:indeterminate="selectionCount > 0 && selectionCount < selectableRows().length"
@change="toggleSelectAllReady"
>
全选可下载
</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="busyKey === 'batch'" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
@@ -275,19 +262,25 @@ onMounted(load)
</div>
</el-card>
<div v-loading="loading" class="shop-summary-row">
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalShops"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; cardPage = 1; selection = new Set(); load() }"
/>
<div v-loading="loading" class="list-toolbar">
<div class="lt-buttons">
<el-checkbox
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
:indeterminate="selectionCount > 0 && selectionCount < selectableRows().length"
@change="toggleSelectAllReady"
>
全选可下载
</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="busyKey === 'batch'" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
<span class="list-toolbar-summary">共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
</div>
<div v-if="allResultRows.length" class="shop-card-grid">
<template v-if="allResultRows.length">
<div class="shop-card-grid">
<article v-for="row in pagedResultRows" :key="row.resultId || `${row.taskNo}-${row.filename}`" class="shop-card">
<header class="shop-card-head">
<span class="shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
@@ -322,7 +315,6 @@ onMounted(load)
</div>
<div class="shop-card-actions">
<el-button
text
type="success"
size="small"
:loading="busyKey === row.resultId"
@@ -333,7 +325,6 @@ onMounted(load)
</el-button>
<el-button
v-if="isTerminalStatus(row.status) && !!row.resultId"
text
type="danger"
size="small"
:loading="deletingKey === row.resultId"
@@ -344,6 +335,16 @@ onMounted(load)
</div>
</article>
</div>
<div class="table-footer">
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalShops"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; cardPage = 1; selection = new Set(); load() }"
/>
</div>
<div v-if="allResultRows.length > cardPageSize" class="table-footer">
<el-pagination
background
@@ -354,11 +355,12 @@ onMounted(load)
@current-change="changeCardPage"
/>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无符合条件的店铺数据任务" />
<el-empty v-else-if="!loading" description="暂无符合条件的店铺数据记录" />
<el-dialog v-model="permissionVisible" title="店铺数据任务数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
<el-dialog v-model="permissionVisible" title="店铺数据记录数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作店铺数据记录的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
<el-table :data="permissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" />
@@ -385,6 +387,10 @@ onMounted(load)
.f-item.wide { width: 340px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.shop-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
.list-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--el-text-color-secondary); font-size: 13px; margin-bottom: 14px; }
.lt-buttons { display: inline-flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.list-toolbar-summary { white-space: nowrap; }
.table-footer { display: flex; justify-content: flex-end; margin-top: 14px; }
.shop-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.shop-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
@@ -395,7 +401,7 @@ onMounted(load)
.shop-card-label { flex: none; width: 52px; color: var(--el-text-color-secondary); }
.shop-card-value { color: var(--el-text-color-regular); min-width: 0; }
.shop-card-file { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.shop-card-actions { display: flex; gap: 6px; padding: 4px 16px 12px; }
.shop-card-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 16px 14px; }
.country-chip { padding: 1px 8px; margin-right: 4px; border-radius: 999px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 12px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+1 -1
View File
@@ -24,7 +24,7 @@ export const adminPages: AdminPageDef[] = [
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
@@ -28,5 +28,5 @@ test('align_shop_data_cards_selection_semantics', () => {
test('align_shop_data_cards_empty_text', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案对齐参考')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案对齐参考')
})
+2 -2
View File
@@ -12,7 +12,7 @@ test('test_task_266_view_normal_primary_path', () => {
assert.match(page, /shop-card-grid/, '需任务卡片网格')
assert.match(page, /v-for="row in pagedResultRows"/, '逐任务渲染卡片')
assert.match(page, /shop-card-task-no/, '卡片含任务号')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
})
test('test_task_266_view_normal_variant_input', () => {
@@ -56,5 +56,5 @@ test('test_task_266_delete_boundary_limit_or_missing_field', () => {
test('test_task_266_dependency_failure_returns_actionable_message', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.equal(/<el-table[^>]*:data="(rows|allResultRows)"/.test(page), false, '结果文件不再用整表渲染')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
})
@@ -52,9 +52,9 @@ public class ProductCategoryController {
@Operation(summary = "分页查询指定父级下的商品类目")
public ApiResponse<ProductCategoryListVo> children(
HttpServletRequest request,
@RequestParam(value = "parentId", required = false) Long parentId,
@RequestParam(value = "parent_id", required = false) Long parentId,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
adminAuthSupport.requireAdminOrInternal(request);
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
}
@@ -65,7 +65,7 @@ public class ProductCategoryController {
HttpServletRequest request,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
adminAuthSupport.requireAdminOrInternal(request);
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
}
@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -38,6 +39,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@@ -75,6 +77,7 @@ public class AdminShopDataCrawlTasksController {
private final AdminAuthSupport adminAuthSupport;
private final PermissionMenuService permissionMenuService;
private final ShopDataCrawlAdminTasksService adminTasksService;
private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper;
@GetMapping
@Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)")
@@ -87,12 +90,14 @@ public class AdminShopDataCrawlTasksController {
@RequestParam(name = "country", required = false) String country,
@RequestParam(name = "created_from", required = false) String createdFrom,
@RequestParam(name = "created_to", required = false) String createdTo) {
requireShopDataCrawlTaskAccess(request);
AdminUserEntity operator = requireShopDataCrawlTaskAccess(request);
int safePage = Math.max(1, parseInt(page, 1, "page 参数不合法"));
int safePageSize = Math.min(PAGE_SIZE_MAX,
Math.max(10, parseInt(pageSize, PAGE_SIZE_DEFAULT, "page_size 参数不合法")));
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
List<String> visibleShopKeys = superAdmin ? null : resolveVisibleShopNameKeys(operator.getId());
Map<String, Object> payload = adminTasksService.listShopGroups(
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo);
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo, visibleShopKeys);
return ApiResponse.success(payload);
}
@@ -235,7 +240,7 @@ public class AdminShopDataCrawlTasksController {
return new ArrayList<>(ids);
}
private void requireShopDataCrawlTaskAccess(HttpServletRequest request) {
private AdminUserEntity requireShopDataCrawlTaskAccess(HttpServletRequest request) {
AdminUserEntity operator;
try {
operator = adminAuthSupport.requireAdmin(request);
@@ -246,6 +251,20 @@ public class AdminShopDataCrawlTasksController {
}
}
permissionMenuService.requireShopDataCrawlTaskAccess(operator);
return operator;
}
/** 非超管返回其可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */
private List<String> resolveVisibleShopNameKeys(Long operatorId) {
List<String> names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId);
if (names == null || names.isEmpty()) {
return List.of();
}
return names.stream()
.filter(name -> name != null && !name.isBlank())
.map(name -> name.trim().toLowerCase(Locale.ROOT))
.distinct()
.toList();
}
private AdminUserEntity resolveInternalOperator(HttpServletRequest request) {
@@ -163,6 +163,15 @@ public interface ShopDataCrawlAdminTasksMapper {
if (p.get("createdTo") != null) {
sql.append(" AND t.created_at <= #{createdTo}");
}
Object visibleShopKeys = p.get("visibleShopKeys");
if (visibleShopKeys instanceof List<?> keys) {
if (keys.isEmpty()) {
sql.append(" AND 1 = 0");
} else {
sql.append(" AND LOWER(TRIM(COALESCE(r.source_filename, ''))) IN (")
.append(inPlaceholders(p, "visibleShopKeys", "lv")).append(')');
}
}
return sql.toString();
}
@@ -44,7 +44,8 @@ public class ShopDataCrawlAdminTasksService {
* 分页列表组装(组总数 → 当前页店铺组 → 每店最新结果行 + 文件任务/分组名补充)。
*/
public Map<String, Object> listShopGroups(int page, int pageSize, String shopName, String groupName,
String country, String createdFromRaw, String createdToRaw) {
String country, String createdFromRaw, String createdToRaw,
List<String> visibleShopKeys) {
LocalDateTime createdFrom = parseAdminDateTime("created_from", createdFromRaw);
LocalDateTime createdTo = parseAdminDateTime("created_to", createdToRaw);
String countryFilter = blankToNull(country);
@@ -60,6 +61,10 @@ public class ShopDataCrawlAdminTasksService {
p.put("country", countryFilter);
p.put("createdFrom", createdFrom);
p.put("createdTo", createdTo);
if (visibleShopKeys != null) {
p.put("visibleShopKeys", visibleShopKeys);
flattenList(p, visibleShopKeys, "visibleShopKeys", "lv");
}
long total = adminTasksMapper.countShopGroups(p);
List<Map<String, Object>> items = new ArrayList<>();
@@ -202,12 +202,12 @@ public class AdminShopDataDuplicateCheckController {
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
try {
response.setContentType("text/csv; charset=utf-8");
DownloadHeaderUtil.setAttachment(response, "shop-data-duplicate-check.csv");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
DownloadHeaderUtil.setAttachment(response, "shop-data-duplicate-check.xlsx");
response.setHeader("Cache-Control", "no-store");
queryService.writeExport(scan, visibleKeys, viewMode, filters, response.getOutputStream());
queryService.writeExportXlsx(scan, visibleKeys, viewMode, filters, response.getOutputStream());
} catch (Exception ex) {
log.warn("[shop-duplicate-check] 导出 CSV 失败: {}", ex.getMessage());
log.warn("[shop-duplicate-check] 导出 Excel 失败: {}", ex.getMessage());
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "导出失败");
}
}
@@ -0,0 +1,38 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
/** 撞款导出 .xlsx 写出(逐行 = 一条上架记录,首行为表头)。 */
public final class DuplicateCheckXlsxWriter {
private DuplicateCheckXlsxWriter() {
}
public static void write(OutputStream out, List<List<String>> rows) throws IOException {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
Sheet sheet = workbook.createSheet("店铺数据");
int rowCount = rows == null ? 0 : rows.size();
for (int i = 0; i < rowCount; i++) {
List<String> row = rows.get(i);
Row xlsRow = sheet.createRow(i);
int colCount = row == null ? 0 : row.size();
for (int c = 0; c < colCount; c++) {
String value = row.get(c);
xlsRow.createCell(c).setCellValue(value == null ? "" : value);
}
}
int cols = rows == null || rows.isEmpty() ? 0 : rows.get(0).size();
for (int c = 0; c < cols; c++) {
sheet.setColumnWidth(c, 14 * 256);
}
workbook.write(out);
workbook.dispose();
}
}
}
@@ -395,6 +395,18 @@ public class ShopDataDuplicateCheckQueryService {
public void writeExport(DuplicateScanView view, Set<String> visibleKeys,
String viewMode, Filters filters, OutputStream out) throws IOException {
DuplicateCheckCsvWriter.write(out, buildExportRows(view, visibleKeys, viewMode, filters));
}
/** 撞款/台账导出为 .xlsx(后台「导出」按钮下载)。 */
public void writeExportXlsx(DuplicateScanView view, Set<String> visibleKeys,
String viewMode, Filters filters, OutputStream out) throws IOException {
DuplicateCheckXlsxWriter.write(out, buildExportRows(view, visibleKeys, viewMode, filters));
}
/** 组装导出行:首行表头 + 逐条上架记录(撞款 view=monitor 过滤同店≥2,台账 view=all 全量)。 */
private List<List<String>> buildExportRows(DuplicateScanView view, Set<String> visibleKeys,
String viewMode, Filters filters) {
boolean monitor = "monitor".equals(viewMode);
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
@@ -412,7 +424,7 @@ public class ShopDataDuplicateCheckQueryService {
for (Object[] row : flat) {
rows.add(toStringRow(row));
}
DuplicateCheckCsvWriter.write(out, rows);
return rows;
}
public List<DuplicateItem> filterItems(List<DuplicateItem> items, boolean monitor, Filters f) {
@@ -41,7 +41,7 @@ import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinExcelPar
import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinGroupingConverter;
import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinHistoryAssembler;
import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache;
import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter;
import com.nanri.aiimage.modules.similarasin.util.WpsCellImageWriter;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
@@ -4539,9 +4539,8 @@ public class SimilarAsinTaskService {
Map<String, SimilarAsinResultRowDto> resultMap,
Map<String, SimilarAsinImageEmbedder.ResizedImage> taskImageCache) {
ensureResultAssemblyNotInterrupted();
// Excel 365 "Place in Cell" 图片写完 workbook patch richData 单元格图片结构
// 这不是浮动 Drawing因此点击图片区域会选中单元格图片不能被拖到任意位置也不需要工作表保护
ExcelCellImageWriter.Session excelCellImageSession = ExcelCellImageWriter.createSession();
// 单元格内嵌图片写完 workbook patch WPS DISPIMG 结构WPS/钉钉/飞书/Excel365 均可显示
WpsCellImageWriter.Session cellImageSession = WpsCellImageWriter.createSession();
SimilarAsinImageEmbedder.ImageSpool imageSpool;
try {
Path spoolDir = Files.createTempDirectory(xlsx.getParentFile().toPath(), "similar-asin-images-");
@@ -4642,11 +4641,11 @@ public class SimilarAsinTaskService {
// 行高固定到 IMAGE_ROW_HEIGHT_POINTSExcel 上限 409pt
row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_MAIN,
resultRow.getMainUrl(), row, taskImageCache, excelCellImageSession, imageSpool);
resultRow.getMainUrl(), row, taskImageCache, cellImageSession, imageSpool);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE1,
resultRow.getPuzzleImg1(), row, taskImageCache, excelCellImageSession, imageSpool);
resultRow.getPuzzleImg1(), row, taskImageCache, cellImageSession, imageSpool);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE2,
resultRow.getPuzzleImg2(), row, taskImageCache, excelCellImageSession, imageSpool);
resultRow.getPuzzleImg2(), row, taskImageCache, cellImageSession, imageSpool);
}
rowIndex++;
}
@@ -4654,14 +4653,14 @@ public class SimilarAsinTaskService {
workbook.write(fos);
workbook.dispose();
}
if (!excelCellImageSession.isEmpty()) {
if (!cellImageSession.isEmpty()) {
try {
ensureResultAssemblyNotInterrupted();
ExcelCellImageWriter.patchXlsxFile(xlsx, excelCellImageSession);
WpsCellImageWriter.patchXlsxFile(xlsx, cellImageSession);
} catch (IOException ex) {
String cause = ex.toString();
log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}",
xlsx.getAbsolutePath(), excelCellImageSession.size(), cause, ex);
xlsx.getAbsolutePath(), cellImageSession.size(), cause, ex);
throw new BusinessException("写入相似ASIN结果 Excel 单元格图片失败: " + cause, ex);
}
}
@@ -569,8 +569,6 @@ public class SimilarAsinImageEmbedder {
* 失败时返回 null;成功返回缩略图。给 Excel richData 单元格图片写入路径使用。
*/
private ResizedImage downloadAndResize(String trimmedUrl,
int colIdx,
Row row,
Map<String, ResizedImage> taskImageCache) {
long startedNanos = System.nanoTime();
try {
@@ -605,7 +603,6 @@ public class SimilarAsinImageEmbedder {
log.warn("[similar-asin][image] embed-fail url={} elapsedMs={} err={}",
trimmedUrl, elapsedMs(startedNanos), errorSummary(ex));
}
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
}
@@ -614,7 +611,7 @@ public class SimilarAsinImageEmbedder {
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
ExcelCellImageWriter.Session excelCellImageSession) {
WpsCellImageWriter.Session excelCellImageSession) {
return embedAsExcelCellImage(rowIdx, colIdx, url, row, taskImageCache, excelCellImageSession, null);
}
@@ -623,46 +620,39 @@ public class SimilarAsinImageEmbedder {
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
ExcelCellImageWriter.Session excelCellImageSession,
WpsCellImageWriter.Session excelCellImageSession,
ImageSpool imageSpool) {
if (url == null || url.isBlank() || excelCellImageSession == null) {
return null;
}
String trimmedUrl = url.trim();
SpoolImage spooled = imageSpool == null ? null : imageSpool.get(trimmedUrl);
String imageId = null;
ImageDim dim = null;
if (spooled != null) {
Cell cell = row.createCell(colIdx);
cell.setCellValue("#VALUE!");
excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path());
return new ImageDim(spooled.width(), spooled.height());
}
if (imageSpool != null) {
row.createCell(colIdx).setCellValue(trimmedUrl);
// 预取已落盘:按路径注册,patch 阶段再读文件,避免整份图片字节驻留堆内存。
imageId = excelCellImageSession.registerImageByPath(spooled.path());
dim = new ImageDim(spooled.width(), spooled.height());
} else if (imageSpool != null) {
// 预取未命中说明该 URL 下载失败:不重试、不把 URL 文本写进单元格,留空并打日志。
log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (prefetch did not spool), leave blank",
trimmedUrl, colIdx);
return null;
}
ResizedImage thumb = downloadAndResize(trimmedUrl, colIdx, row, taskImageCache);
if (thumb == null) {
return null;
}
try {
Cell cell = row.createCell(colIdx);
cell.setCellValue("#VALUE!");
if (imageSpool == null) {
excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes());
} else {
spooled = imageSpool.put(trimmedUrl, thumb);
excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path());
}
taskImageCache.remove(trimmedUrl);
return spooled == null
? new ImageDim(thumb.width(), thumb.height())
: new ImageDim(spooled.width(), spooled.height());
} catch (IOException | RuntimeException ex) {
taskImageCache.remove(trimmedUrl);
log.warn("[similar-asin][image] excel-cell-image-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache);
if (thumb == null) {
log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (download failed), leave blank",
trimmedUrl, colIdx);
return null;
}
imageId = excelCellImageSession.registerImage(thumb.bytes());
taskImageCache.remove(trimmedUrl);
dim = new ImageDim(thumb.width(), thumb.height());
}
if (imageId != null) {
row.createCell(colIdx).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId));
}
return dim;
}
private boolean hasLocalCachedThumb(String url) {
@@ -136,7 +136,7 @@ public final class WpsCellImageWriter {
buildCellImagesRelsXml(session).getBytes(StandardCharsets.UTF_8));
int idx = 1;
for (RegisteredImage img : session.images) {
writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes);
writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes());
idx++;
}
}
@@ -299,6 +299,8 @@ public final class WpsCellImageWriter {
final List<RegisteredImage> images = new ArrayList<>();
// 字节级去重:相同图片只写一次 cellimage{N}.jpeg,节省 xlsx 体积。
private final Map<String, String> bytesHashToId = new LinkedHashMap<>();
// 磁盘路径去重:同一任务内相同源文件(预取 spool 同一份)只写一次 media,避免内存/体积膨胀。
private final Map<String, String> pathHashToId = new LinkedHashMap<>();
public synchronized String registerImage(byte[] jpegBytes) {
if (jpegBytes == null || jpegBytes.length == 0) {
@@ -315,6 +317,22 @@ public final class WpsCellImageWriter {
return id;
}
/** 按磁盘路径注册(预取 spool 已落盘,避免整份字节驻留内存);按绝对路径去重,重复路径复用同一 media。 */
public synchronized String registerImageByPath(Path path) {
if (path == null) {
return null;
}
String normalized = path.toAbsolutePath().normalize().toString();
String existingId = pathHashToId.get(normalized);
if (existingId != null) {
return existingId;
}
String id = newImageId();
images.add(new RegisteredImage(id, path));
pathHashToId.put(normalized, id);
return id;
}
public synchronized boolean isEmpty() {
return images.isEmpty();
}
@@ -347,10 +365,22 @@ public final class WpsCellImageWriter {
static final class RegisteredImage {
final String id;
final byte[] bytes;
final Path sourcePath;
RegisteredImage(String id, byte[] bytes) {
this.id = id;
this.bytes = bytes;
this.sourcePath = null;
}
RegisteredImage(String id, Path sourcePath) {
this.id = id;
this.bytes = null;
this.sourcePath = sourcePath;
}
byte[] bytes() throws IOException {
return sourcePath != null ? Files.readAllBytes(sourcePath) : bytes;
}
}
}
@@ -0,0 +1,4 @@
-- image_history 后台分页查询提速:按创建时间倒序取 id + 按用户过滤
ALTER TABLE image_history
ADD INDEX idx_image_history_created_at (created_at),
ADD INDEX idx_image_history_user_id (user_id);
@@ -229,12 +229,12 @@ class SimilarAsinImageEmbedderTest {
var row = workbook.createSheet().createRow(1);
SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage(
1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool);
1, 9, url, row, new HashMap<>(), WpsCellImageWriter.createSession(), spool);
assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch);
assertEquals(callsAfterPrefetch, networkCalls.get());
assertNull(result);
assertEquals(url, row.getCell(9).getStringCellValue());
assertNull(row.getCell(9), "预取失败不应把 URL 文本写进单元格,应留空");
} finally {
failedEmbedder.shutdown();
}