task-284(收尾/在途批次): 页头按钮归位卡内toolbar+店铺数据记录改名+弹窗报错清除; Java page_size配对/按角色数据范围过滤/撞款导出XLSX/Excel图改WPS DISPIMG/V110查询索引
This commit is contained in:
@@ -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"
|
||||
@@ -343,22 +334,33 @@ onMounted(load)
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="allResultRows.length > cardPageSize" class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="allResultRows.length"
|
||||
:page-size="cardPageSize"
|
||||
:current-page="cardPage"
|
||||
@current-change="changeCardPage"
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="allResultRows.length"
|
||||
:page-size="cardPageSize"
|
||||
:current-page="cardPage"
|
||||
@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>
|
||||
|
||||
@@ -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, /暂无符合条件的店铺数据记录/, '空态文案对齐参考')
|
||||
})
|
||||
|
||||
@@ -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, /暂无符合条件的店铺数据记录/, '空态文案')
|
||||
})
|
||||
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
|
||||
+22
-3
@@ -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) {
|
||||
|
||||
+9
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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<>();
|
||||
|
||||
+4
-4
@@ -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, "导出失败");
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+9
-10
@@ -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_POINTS(Excel 上限 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);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-33
@@ -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());
|
||||
} else {
|
||||
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);
|
||||
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);
|
||||
return null;
|
||||
dim = new ImageDim(thumb.width(), thumb.height());
|
||||
}
|
||||
if (imageId != null) {
|
||||
row.createCell(colIdx).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId));
|
||||
}
|
||||
return dim;
|
||||
}
|
||||
|
||||
private boolean hasLocalCachedThumb(String url) {
|
||||
|
||||
+31
-1
@@ -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);
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user