Files
crawler-plugin/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue
T

501 lines
18 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.
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import {
fetchDedupeTotalList,
updateDedupeTotal,
deleteDedupeTotal,
fetchDedupeTotalGroups,
type DedupeGroupOption,
} from './dedupe-total-api.ts'
import { dedupeGroupSummaryOf, type DedupeTotalItem } from './dedupe-total-model.ts'
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
import {
fetchDedupeDeleteImportProgress,
fetchDedupeImportProgress,
startDedupeDeleteImport,
startDedupeImport,
} from './dedupe-import-api.ts'
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
const groupSummary = computed(() =>
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
)
const editVisible = ref(false)
const editSaving = ref(false)
const editTarget = ref<DedupeTotalItem | null>(null)
const editValue = ref('')
const editGroupId = ref<number | null>(null)
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
const exporting = ref(false)
/** 导出等待遮罩与秒表(对齐 admin.js showDedupeTotalDataExportWait)。 */
const exportWaitVisible = ref(false)
const exportWaitSeconds = ref(0)
let exportWaitTimer: ReturnType<typeof setInterval> | null = null
function startExportWait(): void {
exportWaitSeconds.value = 0
exportWaitVisible.value = true
exportWaitTimer = setInterval(() => {
exportWaitSeconds.value += 1
}, 1000)
}
function stopExportWait(): void {
if (exportWaitTimer) {
clearInterval(exportWaitTimer)
exportWaitTimer = null
}
exportWaitVisible.value = false
}
async function load(): Promise<void> {
loading.value = true
try {
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize))
rows.value = result.items
total.value = result.total
if (result.page >= 1) page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally {
loading.value = false
}
}
function apply(): void {
page.value = 1
void load()
}
function reset(): void {
Object.assign(filter, createDedupeTotalFilterState())
page.value = 1
void load()
}
function openEdit(row: DedupeTotalItem): void {
editTarget.value = row
editValue.value = row.dataValue
editGroupId.value = row.groupId
editVisible.value = true
}
async function saveEdit(): Promise<void> {
const value = editValue.value.trim()
const target = editTarget.value
if (!target) return
if (!value) {
ElMessage.warning('请填写总数据值')
return
}
if (!editGroupId.value) {
ElMessage.warning('请选择分组')
return
}
editSaving.value = true
try {
await updateDedupeTotal(target.id, value, editGroupId.value)
editVisible.value = false
ElMessage.success('保存成功')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
editSaving.value = false
}
}
async function removeRow(row: DedupeTotalItem): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteDedupeTotal(row.id)
ElMessage.success('删除成功')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
}
function resetImport(): void {
importGroupId.value = null
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function importValid(): boolean {
if (!importGroupId.value) {
ElMessage.warning('请先选择数据权限分组')
return false
}
if (!importFile.value) {
ElMessage.warning('请选择 Excel 文件')
return false
}
if (!isAllowedImportFile(importFile.value.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return false
}
return true
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function pollImport(poll: () => Promise<DedupeImportProgress>): Promise<DedupeImportProgress> {
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
importProgress.value = importOutcomeText(progress)
if (importTaskFinished(progress)) return progress
await sleep(1000)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
}
async function submitImport(): Promise<void> {
if (!importValid()) return
const file = importFile.value as File
const groupId = importGroupId.value as number
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
importRunning.value = true
importProgress.value = mode === 'add' ? '正在上传并解析文件…' : '正在上传删除清单…'
try {
const started =
mode === 'add' ? await startDedupeImport(file, groupId) : await startDedupeDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchDedupeImportProgress(started) : () => fetchDedupeDeleteImportProgress(started)
const finished = await pollImport(poll)
importVisible.value = false
resetImport()
if (finished.status === 'success') {
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
} else {
ElMessage.error(importOutcomeText(finished))
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
async function doExport(): Promise<void> {
const { startDate, endDate } = filter
if (startDate && endDate && startDate > endDate) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
if (exporting.value) return
exporting.value = true
startExportWait()
try {
// 对齐 admin.js:fetch 流式下载,带筛选参数;失败响应(JSON)解析错误提示。
const response = await window.fetch(toExportUrl(filter), { credentials: 'include' })
const contentType = response.headers.get('content-type') || ''
if (!response.ok || contentType.includes('application/json')) {
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string; msg?: string }
throw new Error(body.error || body.message || body.msg || '导出失败')
}
const blob = await response.blob()
const disposition = response.headers.get('content-disposition') || ''
const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
const filename = matched ? decodeURIComponent(matched[1]) : 'dedupe-total-data.xlsx'
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
ElMessage.success('导出完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导出失败,请重试')
} finally {
stopExportWait()
exporting.value = false
}
}
onMounted(() => {
void load()
fetchDedupeTotalGroups().then((items) => {
groups.value = items
})
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数据去重总数据</h2>
<p>去重总数据的 ASIN 值台账可按 ASIN/用户名/分组/国家/日期筛选支持 Excel 导入与导出</p>
</div>
<div class="heading-actions">
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
<el-button @click="openImportDelete">删除导入</el-button>
<el-button :loading="exporting" @click="doExport">导出</el-button>
</div>
</div>
<el-card shadow="never" class="group-summary-card">
<div class="group-summary">
<span class="group-summary-title">数据权限分组</span>
<div class="group-summary-body">
<template v-if="groupSummary.kind === 'all'">
<span class="summary-chip">全部分组 · {{ groupSummary.count }} </span>
</template>
<span v-else-if="groupSummary.kind === 'none'" class="dim">未加入分组</span>
<template v-else>
<span v-for="chip in groupSummary.chips" :key="chip.name" class="summary-chip" :title="chip.name">
{{ chip.name }} · {{ chip.relation }}
</span>
<span v-if="groupSummary.extra > 0" class="dim"> {{ groupSummary.extra }} </span>
</template>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>ASIN / 关键字</label>
<el-input v-model="filter.keyword" placeholder="模糊搜索值" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>用户名上传人</label>
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item">
<label>开始日期</label>
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
</div>
<div class="f-item">
<label>结束日期</label>
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
<el-table-column label="国家" min-width="100" align="center">
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="分组" min-width="150">
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
/>
</div>
</el-card>
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
<el-form label-position="top">
<el-form-item label="总数据值ASIN">
<el-input v-model="editValue" placeholder="请输入总数据值" />
</el-form-item>
<el-form-item label="分组">
<el-select v-model="editGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入去重数据' : '删除导入(按 Excel 批量删除)'" width="520px">
<el-form label-position="top">
<el-form-item label="数据权限分组">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
<div class="export-wait">
<span class="wait-spinner" aria-hidden="true"></span>
<div>
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="export-wait-detail">数据量较大请耐心等待并保持页面打开已等待 {{ exportWaitSeconds }} </div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.group-summary-card { margin-bottom: 12px; }
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.summary-chip {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 12.5px;
line-height: 20px;
}
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
.export-wait-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(24, 29, 43, 0.45);
backdrop-filter: blur(3px);
}
.export-wait {
display: flex;
align-items: center;
gap: 14px;
background: var(--el-bg-color);
border-radius: 14px;
padding: 22px 28px;
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
}
.wait-spinner {
width: 26px;
height: 26px;
flex: none;
border: 3px solid var(--el-color-primary-light-7);
border-top-color: var(--el-color-primary);
border-radius: 50%;
animation: dedupe-spin 0.9s linear infinite;
}
@keyframes dedupe-spin {
to { transform: rotate(360deg); }
}
.export-wait-title { font-size: 15px; font-weight: 600; }
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
</style>