Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue
2026-04-06 22:26:44 +08:00

794 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<BrandTopBar active="dedupe" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要清洗的 Excel 文件或文件夹后续将按所选规则输出处理结果</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectCleanFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectCleanFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder">
<template v-if="cleanSelectedPaths.length">
<span v-for="path in cleanDisplayPaths" :key="path">{{ path }}</span>
<span v-if="cleanSelectedPaths.length > cleanDisplayPaths.length" class="more-line">
还有 {{ cleanSelectedPaths.length - cleanDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待清洗文件</span>
</div>
</div>
<div class="section-title">保留列设置</div>
<div class="option-group column-option-group">
<div class="column-toolbar">
<span class="column-count">已选择 {{ cleanSelectedColumns.length }} / {{ cleanAvailableColumns.length }}
</span>
<div class="column-actions">
<button type="button" class="opt-btn small" @click="selectAllCleanColumns">全选</button>
<button type="button" class="opt-btn small" @click="clearAllCleanColumns">清空</button>
</div>
</div>
<div class="column-grid" v-if="cleanAvailableColumns.length">
<label v-for="column in cleanAvailableColumns" :key="column" class="column-item"
:class="{ active: cleanSelectedColumns.includes(column) }">
<input v-model="cleanSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span>
</label>
</div>
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
<div class="desc">读取首个 Excel 文件的第一行作为表头用于动态选择需要保留的列</div>
</div>
<div class="section-title">ID 保留规则</div>
<div class="option-group">
<!-- <label class="radio-item" :class="{ active: cleanKeepIntegerIds }">
<input v-model="cleanKeepIntegerIds" type="checkbox" />
<div>
<span class="label">保留主链接</span>
<div class="desc">例如 123 这种纯数字 ID</div>
</div>
</label> -->
<label class="radio-item" :class="{ active: cleanKeepUnderscoreIds }">
<input v-model="cleanKeepUnderscoreIds" type="checkbox" />
<div>
<span class="label">保留子链接</span>
<div class="desc">例如 1_12_3 这种带下划线的子项 ID</div>
</div>
</label>
<label class="radio-item" :class="{ active: cleanKeepIntegerMainIdsWhenNoSubIds }">
<input v-model="cleanKeepIntegerMainIdsWhenNoSubIds" type="checkbox" />
<div>
<span class="label">仅剩主链接无子链接</span>
<div class="desc">当某个主链接只有 13 这类主 ID没有 13_1 这类对应子 ID 自动保留该主 ID</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="cleanRunning" @click="submitCleanRun">
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
</button>
<span class="loading-msg">
{{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">去重结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ cleanSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ cleanSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ cleanSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理后的 Excel 列表</span>
</div>
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
暂无去重结果完成数据去重后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in cleanResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && item.downloadUrl" type="button" class="download"
@click="downloadCleanResult(item)">
下载文件
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteCleanHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteDedupeHistory, getDedupeHistory, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const cleanAvailableColumns = ref<string[]>([])
const cleanSelectedColumns = ref<string[]>([])
const cleanSelectedPaths = ref<string[]>([])
const cleanArchiveName = ref('')
const cleanUploadedFiles = ref<UploadedJavaFile[]>([])
const cleanKeepIntegerIds = ref(false)
const cleanKeepUnderscoreIds = ref(true)
const cleanKeepIntegerMainIdsWhenNoSubIds = ref(true)
const cleanRunning = ref(false)
const cleanResultItems = ref<DedupeResultItem[]>([])
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
function selectAllCleanColumns() {
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
}
function clearAllCleanColumns() {
cleanSelectedColumns.value = []
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
const uploaded: UploadedJavaFile[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const result = await api.upload_file_to_java(filePath, relativePath)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
}
uploaded.push(result.data)
}
return uploaded
}
async function loadCleanHeaders(fileKey: string) {
const result = await getExcelInfo(fileKey)
if (!result.headers?.length) {
ElMessage.error('读取 Excel 表头失败')
cleanAvailableColumns.value = []
cleanSelectedColumns.value = []
return
}
const preferredHeaders = ['id', 'ASIN', '国家', '价格', '卖家名称']
const orderedHeaders = [
...preferredHeaders.filter((header) => result.headers?.includes(header)),
...(result.headers || []).filter((header) => !preferredHeaders.includes(header)),
]
cleanAvailableColumns.value = orderedHeaders
cleanSelectedColumns.value = preferredHeaders.filter((header) => orderedHeaders.includes(header))
}
async function handleSelectedPaths(paths: string[], successMessage: string) {
cleanSelectedPaths.value = paths
cleanArchiveName.value = ''
cleanUploadedFiles.value = await uploadPathsToJava(paths)
if (cleanUploadedFiles.value.length > 0) {
await loadCleanHeaders(cleanUploadedFiles.value[0].fileKey)
}
ElMessage.success(successMessage)
}
async function selectCleanFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待清洗文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectCleanFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
cleanArchiveName.value = folder.split(/[/\\]/).filter(Boolean).pop() || ''
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
cleanSelectedPaths.value = result.items.map((item) => item.relativePath)
cleanUploadedFiles.value = await uploadPathsToJava(result.items)
if (cleanUploadedFiles.value.length > 0) {
await loadCleanHeaders(cleanUploadedFiles.value[0].fileKey)
}
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitCleanRun() {
if (!cleanUploadedFiles.value.length) {
ElMessage.warning('请先选择待清洗 Excel 文件或文件夹')
return
}
if (!cleanSelectedColumns.value.length) {
ElMessage.warning('请至少选择一列保留列')
return
}
if (!cleanKeepIntegerIds.value && !cleanKeepUnderscoreIds.value && !cleanKeepIntegerMainIdsWhenNoSubIds.value) {
ElMessage.warning('请至少选择一种 ID 保留规则')
return
}
try {
cleanRunning.value = true
const result = await runDedupe({
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
selectedColumns: cleanSelectedColumns.value,
keepIntegerIds: cleanKeepIntegerIds.value,
keepUnderscoreIds: cleanKeepUnderscoreIds.value,
keepIntegerMainIdsWhenNoSubIds: cleanKeepIntegerMainIdsWhenNoSubIds.value,
archiveName:
cleanUploadedFiles.value.some((item) => !!item.relativePath) && cleanArchiveName.value
? cleanArchiveName.value
: undefined,
})
cleanSummary.value = result
cleanResultItems.value = result.items || []
await loadCleanHistory()
ElMessage.success('数据去重完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重失败')
} finally {
cleanRunning.value = false
}
}
async function loadCleanHistory() {
try {
const response = await getDedupeHistory()
cleanResultItems.value = response.items || []
cleanSummary.value = {
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch {
// ignore history load errors
}
}
async function deleteCleanHistoryRecord(resultId: number) {
try {
await deleteDedupeHistory(resultId)
await loadCleanHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function downloadCleanResult(item: DedupeResultItem) {
const api = getPywebviewApi()
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(item.downloadUrl, '_blank')
}
onMounted(() => {
loadCleanHistory().catch(() => undefined)
})
</script>
<style scoped>
.module-page {
min-height: 100vh;
background: #1a1a1a;
}
.main-content {
display: flex;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
}
.left-panel {
width: 380px;
background: #1e1e1e;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2a2a2a;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #1a1a1a;
}
.section-title {
font-size: 13px;
color: #bbb;
margin-bottom: 10px;
}
.upload-zone {
border: 1px dashed #3a3a3a;
border-radius: 10px;
padding: 24px;
text-align: center;
background: #252525;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2a2a2a;
}
.hint {
color: #888;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
}
.btns {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.opt-btn,
.btn-run,
.download,
.btn-delete {
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.opt-btn {
padding: 8px 16px;
font-size: 13px;
color: #ccc;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
.opt-btn.small {
padding: 6px 10px;
font-size: 12px;
}
.opt-btn:hover {
color: #3498db;
background: #333;
border-color: #3498db;
}
.selected-files {
margin-top: 14px;
font-size: 12px;
color: #888;
max-height: 112px;
overflow-y: auto;
text-align: left;
}
.selected-files span {
display: block;
margin: 4px 0;
word-break: break-all;
}
.more-line {
color: #b8c1cc;
}
.option-group {
margin-bottom: 20px;
}
.column-option-group {
padding: 14px;
border-radius: 10px;
background: #252525;
border: 1px solid #2a2a2a;
}
.column-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.column-count {
font-size: 12px;
color: #a9b4bf;
}
.column-actions {
display: flex;
gap: 8px;
}
.column-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.empty-column-state {
padding: 16px 12px;
border-radius: 8px;
border: 1px dashed #3a3a3a;
background: #202020;
color: #7f8790;
font-size: 12px;
line-height: 1.6;
}
.column-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #2f2f2f;
background: #202020;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
color: #d7d7d7;
}
.column-item:hover,
.column-item.active {
border-color: #3b6f98;
background: #26313a;
}
.column-item input {
margin: 0;
}
.column-item span {
word-break: break-all;
}
.radio-item {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #252525;
border: 1px solid #2a2a2a;
margin-bottom: 8px;
transition: all 0.2s ease;
}
.radio-item:hover,
.radio-item.active {
background: #2a2a2a;
border-color: #3a3a3a;
}
.radio-item input {
margin-top: 3px;
}
.label {
font-weight: 500;
color: #e0e0e0;
font-size: 13px;
}
.desc {
font-size: 12px;
color: #888;
margin-top: 2px;
line-height: 1.5;
font-weight: 400;
}
.run-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 16px;
}
.btn-run {
padding: 10px 22px;
background: #3498db;
color: #fff;
border-radius: 8px;
font-size: 14px;
}
.btn-run:hover {
background: #2980b9;
}
.btn-run:disabled {
background: #555;
color: #999;
cursor: not-allowed;
}
.loading-msg {
color: #3498db;
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2a2a2a;
font-size: 15px;
font-weight: 600;
color: #ddd;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2a2a2a;
border-radius: 8px;
margin-bottom: 10px;
background: #1e1e1e;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #e0e0e0;
font-size: 13px;
}
.files {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
}
.download:hover {
background: rgba(52, 152, 219, 0.28);
}
.btn-delete {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(231, 76, 60, 0.12);
color: #ff8f8f;
}
.btn-delete:hover {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
color: #666;
font-size: 13px;
padding: 24px 8px;
}
.clean-placeholder {
max-height: none;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #eaf4ff;
}
.summary-label {
font-size: 12px;
color: #8d8d8d;
}
.result-list-wrap {
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2a2a2a;
font-size: 14px;
color: #ddd;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
height: auto;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid #2a2a2a;
}
.right-panel {
min-height: 420px;
}
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style>