501 lines
18 KiB
Vue
501 lines
18 KiB
Vue
<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>
|