feat(trademark): 实现商标筛查功能并优化相关配置

- 新增商标筛查进度展示界面与交互逻辑
- 实现产品、品牌及平台跟卖许可的分项任务进度追踪
- 添加商标数据导出与任务重试、取消功能
- 调整Redis连接池配置以提升并发性能
- 禁用ChromeDriver预加载,改为按需启动以节省资源- 支持品牌商标远程筛查接口调用与结果解析
- 增加Hutool工具库依赖用于简化IO与Excel处理- 更新USPTO商标查询脚本实现自动化检测
- 修改Ruoyi后台Redis依赖版本并添加集群心跳配置- 切换本地开发环境API地址指向内网测试服务器
This commit is contained in:
2025-11-04 15:39:15 +08:00
parent c9874f1786
commit a62d7b6147
22 changed files with 2063 additions and 168 deletions

View File

@@ -3,6 +3,7 @@ import { ref, inject, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
import { markApi } from '../../api/mark'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
@@ -17,12 +18,21 @@ const emit = defineEmits<{
const trademarkData = ref<any[]>([])
const trademarkFileName = ref('')
const trademarkFile = ref<File | null>(null)
const trademarkUpload = ref<HTMLInputElement | null>(null)
const trademarkLoading = ref(false)
const trademarkProgress = ref(0)
const exportLoading = ref(false)
const currentStep = ref(0)
const totalSteps = ref(0)
let brandProgressTimer: any = null
// 三个任务的进度数据
const taskProgress = ref({
product: { total: 0, current: 0, completed: 0, label: '产品商标筛查', desc: '筛查未注册商标或TM标的商品' },
brand: { total: 0, current: 0, completed: 0, label: '品牌商标筛查', desc: '筛查未注册商标的品牌' },
platform: { total: 0, current: 0, completed: 0, label: '跟卖许可筛查', desc: '筛查亚马逊许可跟卖的商品' }
})
// 查询状态: idle-待开始, inProgress-进行中, done-已完成, error-错误, networkError-网络错误, cancel-已取消
const queryStatus = ref<'idle' | 'inProgress' | 'done' | 'error' | 'networkError' | 'cancel'>('idle')
@@ -39,14 +49,14 @@ const statusConfig = {
desc: '请耐心等待,正在为您筛查商标信息'
},
done: {
icon: '/icon/done.png',
icon: '/icon/done1.png',
title: '筛查完成',
desc: '已成功完成所有商标筛查任务'
},
error: {
icon: '/icon/error.png',
title: '查失败',
desc: '查过程中出现错误,请重试'
title: '查失败',
desc: '查过程中出现错误,请重新尝试'
},
networkError: {
icon: '/icon/networkErrors.png',
@@ -61,19 +71,15 @@ const statusConfig = {
}
// 选择的账号(模拟数据)
const selectedAccount = ref('chensri3.com')
const selectedAccount = ref(1)
const accountOptions = [
{ label: 'chensri3.com', value: 'chensri3.com', status: '美国' },
{ label: 'zhangikcpi.com', value: 'zhangikcpi.com', status: '加拿大' },
{ label: 'hhhhsoutlook...', value: 'hhhhsoutlook', status: '日本' }
{ id: 1, name: 'chensri3.com', username: 'chensri3.com', status: 1 }
]
// 地区选择
const region = ref('美国')
const regionOptions = [
{ label: '美国', value: '美国', flag: '🇺🇸' },
{ label: '日本', value: '日本', flag: '🇯🇵' },
{ label: '加拿大', value: '加拿大', flag: '🇨🇦' }
{ label: '美国', value: '美国', flag: '🇺🇸' }
]
// 查询类型多选
@@ -103,6 +109,7 @@ async function handleTrademarkUpload(e: Event) {
}
trademarkFileName.value = file.name
trademarkFile.value = file
queryStatus.value = 'idle' // 重置状态
trademarkData.value = []
emit('updateData', [])
@@ -111,7 +118,7 @@ async function handleTrademarkUpload(e: Event) {
}
async function startTrademarkQuery() {
if (!trademarkFileName.value) {
if (!trademarkFileName.value || !trademarkFile.value) {
showMessage('请先导入商标列表', 'warning')
return
}
@@ -129,30 +136,231 @@ async function startTrademarkQuery() {
trademarkData.value = []
queryStatus.value = 'inProgress'
const mockData = [
{ name: 'SONY', status: '已注册', class: '9类-电子产品', owner: 'Sony Corporation', expireDate: '2028-12-31', similarity: 0 },
{ name: 'SUMSUNG', status: '近似风险', class: '9类-电子产品', owner: '待查询', expireDate: '-', similarity: 85 },
{ name: 'NIKE', status: '已注册', class: '25类-服装鞋帽', owner: 'Nike, Inc.', expireDate: '2029-06-15', similarity: 0 },
{ name: 'ADIBAS', status: '近似风险', class: '25类-服装鞋帽', owner: '待查询', expireDate: '-', similarity: 92 },
{ name: 'MyBrand', status: '可注册', class: '未查询', owner: '-', expireDate: '-', similarity: 0 }
]
// 完全重置任务进度包括total
taskProgress.value.product.total = 0
taskProgress.value.product.current = 0
taskProgress.value.product.completed = 0
taskProgress.value.brand.total = 0
taskProgress.value.brand.current = 0
taskProgress.value.brand.completed = 0
taskProgress.value.platform.total = 0
taskProgress.value.platform.current = 0
taskProgress.value.platform.completed = 0
totalSteps.value = mockData.length
// 通知父组件更新数据
emit('updateData', [])
try {
for (let i = 0; i < mockData.length; i++) {
if (!trademarkLoading.value) break // 被取消
let productResult: any = null
let brandList: string[] = []
// 判断是否需要执行产品商标筛查
const needProductCheck = queryTypes.value.includes('product')
const needBrandCheck = queryTypes.value.includes('brand')
if (needProductCheck) {
// 步骤1: 产品商标筛查 - 调用新建任务接口
showMessage('正在上传文件...', 'info')
const createResult = await markApi.newTask(trademarkFile.value)
currentStep.value = i + 1
await new Promise(resolve => setTimeout(resolve, 800))
trademarkData.value.push(mockData[i])
trademarkProgress.value = Math.round(((i + 1) / mockData.length) * 100)
if (createResult.code !== 200 && createResult.code !== 0) {
throw new Error(createResult.msg || '创建任务失败')
}
showMessage('文件上传成功,正在处理...', 'success')
// 步骤2: 轮询检查任务状态最多等待60秒
const taskData = taskProgress.value.product
const maxWaitTime = 60000 // 最多等60秒
const pollInterval = 3000 // 每3秒检查一次
const startTime = Date.now()
let taskResult: any = null
while (Date.now() - startTime < maxWaitTime) {
if (!trademarkLoading.value) return
// 等待一段时间
await new Promise(resolve => setTimeout(resolve, pollInterval))
if (!trademarkLoading.value) return
// 尝试获取任务结果
try {
taskResult = await markApi.getTask()
if (taskResult.code === 200 || taskResult.code === 0) {
// 检查是否有 download_url有则说明任务完成
if (taskResult.data.original?.download_url) {
break
}
}
} catch (err) {
// 继续等待
console.log('等待任务处理中...', err)
}
}
if (!trademarkLoading.value) return
// 步骤3: 检查是否成功获取到结果
if (!taskResult || (taskResult.code !== 200 && taskResult.code !== 0)) {
throw new Error('获取任务超时或失败,请重试')
}
if (!taskResult.data.original?.download_url) {
throw new Error('任务处理超时,请稍后重试')
}
productResult = taskResult
// 从后端获取真实的统计数据
taskData.total = taskResult.data.original?.total || 0
taskData.completed = taskResult.data.filtered.length
taskData.current = taskData.total
// 映射后端数据到前端格式
trademarkData.value = taskResult.data.filtered.map((item: any) => ({
name: item['品牌'] || '',
status: item['商标类型'] || '',
class: '',
owner: '',
expireDate: item['注册时间'] || '',
similarity: 0,
// 保留原始数据供后续使用
asin: item['ASIN'],
productImage: item['商品主图']
}))
// 如果需要品牌筛查,从产品结果中提取品牌列表
if (needBrandCheck) {
brandList = taskResult.data.filtered
.map((item: any) => item['品牌'])
.filter((brand: string) => brand && brand.trim())
}
showMessage(`产品筛查完成,共查询 ${taskData.total} 条,筛查出 ${taskData.completed} 条未注册/TM标`, 'success')
}
// 品牌商标筛查
if (needBrandCheck) {
if (!trademarkLoading.value) return
// 如果没有执行产品筛查,需要先上传文件获取品牌列表
if (!needProductCheck) {
showMessage('正在上传文件提取品牌列表...', 'info')
// 调用新建任务接口获取处理后的数据
const createResult = await markApi.newTask(trademarkFile.value)
if (createResult.code !== 200 && createResult.code !== 0) {
throw new Error(createResult.msg || '创建任务失败')
}
// 轮询获取任务结果
const maxWaitTime = 60000
const pollInterval = 3000
const startTime = Date.now()
let taskResult: any = null
while (Date.now() - startTime < maxWaitTime) {
if (!trademarkLoading.value) return
await new Promise(resolve => setTimeout(resolve, pollInterval))
if (!trademarkLoading.value) return
try {
taskResult = await markApi.getTask()
if (taskResult.code === 200 || taskResult.code === 0) {
if (taskResult.data.original?.download_url) {
break
}
}
} catch (err) {
console.log('等待任务处理中...', err)
}
}
if (!trademarkLoading.value) return
if (!taskResult || (taskResult.code !== 200 && taskResult.code !== 0)) {
throw new Error('获取任务超时或失败,请重试')
}
if (!taskResult.data.original?.download_url) {
throw new Error('任务处理超时,请稍后重试')
}
// 从结果中提取品牌列表包括TM和未注册的品牌
brandList = taskResult.data.filtered
.map((item: any) => item['品牌'])
.filter((brand: string) => brand && brand.trim())
showMessage(`品牌列表提取成功,共 ${brandList.length} 个品牌`, 'success')
}
if (brandList.length === 0) {
showMessage('没有品牌需要筛查', 'warning')
} else {
const brandData = taskProgress.value.brand
brandData.total = brandList.length
brandData.current = 0
brandData.completed = 0
showMessage(`开始品牌商标筛查,共 ${brandList.length} 个品牌...`, 'info')
// 模拟进度动画每秒增加20个
const batchSize = 20
brandProgressTimer = setInterval(() => {
if (brandData.current < brandList.length * 0.95) {
brandData.current = Math.min(brandData.current + batchSize, brandList.length * 0.95)
}
}, 1000)
// 调用品牌筛查接口(浏览器内并发,速度快)
const brandResult = await markApi.brandCheck(brandList)
if (brandProgressTimer) clearInterval(brandProgressTimer)
if (!trademarkLoading.value) return
if (brandResult.code === 200 || brandResult.code === 0) {
// 完成显示100%
brandData.current = brandData.total
brandData.completed = brandResult.data.filtered
// 将品牌筛查结果追加到展示数据中
const brandItems = brandResult.data.data.map((item: any) => ({
name: item.brand || '',
status: item.status || '未注册',
class: '',
owner: '',
expireDate: '',
similarity: 0,
isBrand: true // 标记为品牌数据
}))
trademarkData.value = [...trademarkData.value, ...brandItems]
showMessage(`品牌筛查完成,共查询 ${brandData.total} 个品牌,筛查出 ${brandData.completed} 个未注册品牌`, 'success')
} else {
throw new Error(brandResult.msg || '品牌筛查失败')
}
}
}
if (trademarkLoading.value) {
queryStatus.value = 'done'
emit('updateData', trademarkData.value)
showMessage('商标筛查完成', 'success')
let summaryMsg = '筛查完成'
if (needProductCheck) {
const taskData = taskProgress.value.product
summaryMsg += `,产品:${taskData.completed}/${taskData.total}`
}
if (needBrandCheck && brandList.length > 0) {
const brandData = taskProgress.value.brand
summaryMsg += `,品牌:${brandData.completed}/${brandData.total}`
}
showMessage(summaryMsg, 'success')
}
} catch (error: any) {
if (error.message && error.message.includes('网络')) {
@@ -163,7 +371,16 @@ async function startTrademarkQuery() {
errorMessage.value = error.message || '查询失败'
}
showMessage(errorMessage.value, 'error')
// 失败时清空数据,不显示侧边栏
trademarkData.value = []
emit('updateData', [])
} finally {
// 清除定时器
if (brandProgressTimer) {
clearInterval(brandProgressTimer)
brandProgressTimer = null
}
trademarkLoading.value = false
currentStep.value = 0
totalSteps.value = 0
@@ -171,6 +388,11 @@ async function startTrademarkQuery() {
}
function stopTrademarkQuery() {
// 清除进度动画定时器
if (brandProgressTimer) {
clearInterval(brandProgressTimer)
brandProgressTimer = null
}
trademarkLoading.value = false
queryStatus.value = 'cancel'
currentStep.value = 0
@@ -239,12 +461,28 @@ function downloadTrademarkTemplate() {
URL.revokeObjectURL(url)
}
function resetToIdle() {
queryStatus.value = 'idle'
trademarkData.value = []
trademarkFileName.value = ''
trademarkFile.value = null
taskProgress.value.product.current = 0
taskProgress.value.product.completed = 0
taskProgress.value.brand.current = 0
taskProgress.value.brand.completed = 0
taskProgress.value.platform.current = 0
taskProgress.value.platform.completed = 0
}
defineExpose({
trademarkLoading,
trademarkProgress,
trademarkData,
queryStatus,
statusConfig
statusConfig,
taskProgress,
resetToIdle,
stopTrademarkQuery
})
</script>
@@ -278,19 +516,26 @@ defineExpose({
<div class="step-header"><div class="title">选择亚马逊商家账号</div></div>
<div class="desc">请确保账号地区与专利地区一致不一致可能导致结果不准确或失败</div>
<el-select v-model="selectedAccount" placeholder="选择账号" size="small" style="width: 100%">
<el-option v-for="opt in accountOptions" :key="opt.value" :label="opt.label" :value="opt.value">
<div class="account-option">
<span>{{ opt.label }}</span>
<span class="account-status">{{ opt.status }}</span>
<span class="account-check"></span>
<el-scrollbar :class="['account-list', { 'scroll-limit': accountOptions.length > 3 }]">
<div>
<div
v-for="acc in accountOptions"
:key="acc.id"
:class="['acct-item', 'disabled', { selected: selectedAccount === acc.id }]"
>
<span class="acct-row">
<span :class="['status-dot', acc.status === 1 ? 'on' : 'off']"></span>
<img class="avatar" src="/image/user.png" alt="avatar" />
<span class="acct-text">{{ acc.name || acc.username }}</span>
<span v-if="selectedAccount === acc.id" class="acct-check"></span>
</span>
</div>
</el-option>
</el-select>
</div>
</el-scrollbar>
<div class="step-actions btn-row">
<el-button size="small" class="w50">添加账号</el-button>
<el-button size="small" class="w50 btn-blue">账号管理</el-button>
<el-button size="small" class="w50" disabled>添加账号</el-button>
<el-button size="small" class="w50 btn-blue" disabled>账号管理</el-button>
</div>
</div>
</div>
@@ -301,7 +546,7 @@ defineExpose({
<div class="step-card">
<div class="step-header"><div class="title">选择产品品牌地区</div></div>
<div class="desc">暂时仅支持美国品牌商标筛查后续将开放更多地区敬请期待</div>
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%">
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%" disabled>
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
</el-option>
@@ -337,7 +582,7 @@ defineExpose({
</div>
</div>
<div :class="['query-card', { active: queryTypes.includes('platform') }]" @click="toggleQueryType('platform')">
<div :class="['query-card', 'query-disabled', { active: queryTypes.includes('platform') }]">
<div class="query-check">
<div class="check-icon" v-if="queryTypes.includes('platform')"></div>
</div>
@@ -355,9 +600,12 @@ defineExpose({
<div class="bottom-action">
<div class="action-header">
<span class="step-indicator">{{ trademarkLoading ? currentStep : 1 }}/4</span>
<el-button class="start-btn" type="primary" :disabled="!trademarkFileName || trademarkLoading" :loading="trademarkLoading" @click="startTrademarkQuery">
<el-button v-if="!trademarkLoading" class="start-btn" type="primary" :disabled="!trademarkFileName || queryTypes.length === 0" @click="startTrademarkQuery">
开始筛查
</el-button>
<el-button v-else class="start-btn stop-btn" @click="stopTrademarkQuery">
停止筛查
</el-button>
</div>
</div>
@@ -391,7 +639,7 @@ defineExpose({
.title { font-size: 14px; font-weight: 600; color: #303133; text-align: left; }
.desc { font-size: 12px; color: #909399; margin-bottom: 10px; text-align: left; line-height: 1.5; }
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.link { color: #409EFF; cursor: pointer; font-size: 12px; }
.link { color: #909399; cursor: pointer; font-size: 12px; }
.file-chip {
display: flex;
@@ -402,19 +650,22 @@ defineExpose({
border-radius: 4px;
font-size: 12px;
color: #606266;
margin-top: 6px;
margin-top: 6px;
min-width: 0;
}
.file-chip .dot {
width: 6px;
height: 6px;
background: #409EFF;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.file-chip .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.dropzone {
@@ -459,6 +710,11 @@ defineExpose({
border-color: #1677FF;
background: #f0f9ff;
}
.query-card.query-disabled {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
.query-check {
width: 16px;
height: 16px;
@@ -468,15 +724,15 @@ defineExpose({
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #ffffff;
background: transparent;
transition: all 0.2s ease;
}
.query-card.active .query-check {
border-color: #1677FF;
background: #1677FF;
background: transparent;
}
.check-icon {
color: #ffffff;
color: #1677FF;
font-size: 10px;
font-weight: bold;
line-height: 1;
@@ -534,32 +790,90 @@ defineExpose({
}
.start-btn {
flex: 1;
height: 38px;
height: 32px;
padding: 0px 16px;
background: #1677FF;
border-color: #1677FF;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
}
.stop-btn {
background: #f56c6c;
border-color: #f56c6c;
}
.stop-btn:hover {
background: #f78989;
border-color: #f78989;
}
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
.account-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
.account-list {
height: auto;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
margin-bottom: 8px;
padding: 4px;
}
.account-status {
margin-left: auto;
font-size: 12px;
color: #909399;
.scroll-limit { max-height: 140px; }
.avatar { width: 18px; height: 18px; border-radius: 50%; }
.acct-row {
display: grid;
grid-template-columns: 6px 18px 1fr auto;
align-items: center;
gap: 6px;
width: 100%;
}
.account-check {
color: #1677FF;
font-size: 14px;
.acct-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: 12px;
color: #303133;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.status-dot.on { background: #22c55e; }
.status-dot.off { background: #f87171; }
.acct-item {
padding: 6px 8px;
border-radius: 6px;
margin-bottom: 4px;
}
.acct-item:last-child {
margin-bottom: 0;
}
.acct-item.selected {
background: #eef5ff;
box-shadow: inset 0 0 0 1px #d6e4ff;
}
.acct-item.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.acct-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: transparent;
color: #111;
font-size: 12px;
}
.account-list :deep(.el-scrollbar__view) {
padding: 0;
}
.account-list::-webkit-scrollbar { width: 0; height: 0; }
.trademark-panel :deep(.el-select),
.trademark-panel :deep(.el-input__wrapper) {