task-254(admin.html观感对齐): 去重汇总页对齐(筛选扩充/行编辑删除/导入删除导入轮询/导出)

This commit is contained in:
2026-09-05 21:25:33 +08:00
parent f765b3ce67
commit 88f2ec8bb0
3 changed files with 497 additions and 0 deletions
@@ -0,0 +1,374 @@
<script setup lang="ts">
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 扩充筛选(用户名/日期/分组)、行编辑/删除、新增/删除导入(轮询)、导出。 */
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchDedupeTotalList,
updateDedupeTotal,
deleteDedupeTotal,
fetchDedupeTotalGroups,
type DedupeGroupOption,
} from './dedupe-total-api.ts'
import 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 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[]>([])
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)
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
}
}
function doExport(): void {
const { startDate, endDate } = filter
if (startDate && endDate && startDate > endDate) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
exporting.value = true
window.setTimeout(() => {
const anchor = document.createElement('a')
anchor.href = toExportUrl(filter)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
exporting.value = false
ElMessage.success('导出文件已开始下载')
}, 120)
}
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">
<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" width="80" />
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
<el-table-column label="国家" width="100" align="center">
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="分组" width="150">
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '—' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170" />
<el-table-column label="操作" 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>
</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; }
.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; }
</style>
@@ -1,12 +1,63 @@
/** 去重汇总列表查询适配(任务 62):GET /api/admin/dedupe-total-data + 共享筛选归一。 */ /** 去重汇总列表查询适配(任务 62):GET /api/admin/dedupe-total-data + 共享筛选归一。 */
import { http } from '@/api/http' import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { normalizeAsinPageParams, toAsinPageQuery, type AsinListParams } from './asin-filter' import { normalizeAsinPageParams, toAsinPageQuery, type AsinListParams } from './asin-filter'
import { parseDedupeTotalPage, type DedupeTotalPageResult } from './dedupe-total-model' import { parseDedupeTotalPage, type DedupeTotalPageResult } from './dedupe-total-model'
export const DEDUPE_TOTAL_ENDPOINT = '/api/admin/dedupe-total-data' export const DEDUPE_TOTAL_ENDPOINT = '/api/admin/dedupe-total-data'
/** 数据权限分组选项源(与店铺/去重共用同一分组集)。 */
export const DEDUPE_GROUPS_ENDPOINT = '/api/admin/shop-manage-groups'
export async function fetchDedupeTotalList(params: Partial<AsinListParams> = {}): Promise<DedupeTotalPageResult> { export async function fetchDedupeTotalList(params: Partial<AsinListParams> = {}): Promise<DedupeTotalPageResult> {
const normalized = normalizeAsinPageParams(params) const normalized = normalizeAsinPageParams(params)
const { data } = await http.get<unknown>(DEDUPE_TOTAL_ENDPOINT, { params: toAsinPageQuery(normalized) }) const { data } = await http.get<unknown>(DEDUPE_TOTAL_ENDPOINT, { params: toAsinPageQuery(normalized) })
return parseDedupeTotalPage(data) return parseDedupeTotalPage(data)
} }
export interface DedupeGroupOption {
id: number
name: string
}
/** 编辑单行去重总数据(ASIN 值 + 分组):PUT /dedupe-total-data/{id}。 */
export async function updateDedupeTotal(id: number, dataValue: string, groupId: number): Promise<void> {
const { data } = await http.put<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`, {
data_value: dataValue,
group_id: groupId,
})
unwrap<unknown>(data)
}
/** 删除单行去重总数据:DELETE /dedupe-total-data/{id}。 */
export async function deleteDedupeTotal(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
/** 加载数据权限分组选项:GET /shop-manage-groups → {id,name}[]。 */
export async function fetchDedupeTotalGroups(): Promise<DedupeGroupOption[]> {
const { data } = await http.get<unknown>(DEDUPE_GROUPS_ENDPOINT)
return parseDedupeGroupOptionList(data)
}
/** 归一化分组选项负载(信封 items 或裸数组),缺 id 行丢弃。 */
export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[] {
const core = unwrap<unknown>(payload)
const rawList = Array.isArray(core)
? core
: core && typeof core === 'object'
? (core as Record<string, unknown>).items
: []
if (!Array.isArray(rawList)) return []
const options: DedupeGroupOption[] = []
for (const raw of rawList) {
if (!raw || typeof raw !== 'object') continue
const record = raw as Record<string, unknown>
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
if (!Number.isFinite(id) || id <= 0) continue
const nameRaw = record.group_name ?? record.name
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
options.push({ id, name: name || `分组${id}` })
}
return options
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// module 13 task 254:去重汇总页对齐 admin panel-dedupe-total-data —— 筛选扩充(用户名/分组/日期)、
// 行编辑/删除、新增导入/删除导入(轮询)、导出(日期校验+等待) 接线既有孤儿模块。
test('test_task_254_dedupe_normal_primary_path', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /新增导入/, '页头需有新增导入按钮')
assert.match(page, /删除导入/, '页头需有删除导入按钮')
assert.match(page, /导出/, '页头需有导出入口')
assert.match(page, /编辑/, '行内需有编辑操作')
assert.match(page, /删除/, '行内需有删除操作')
})
test('test_task_254_dedupe_normal_variant_input', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /fetchDedupeTotalGroups/, '需接线分组列表源')
assert.match(page, /toDedupeListParams/, '需走共享筛选归一(dedupe-total-filter)')
assert.match(page, /importFinished|importTaskFinished/, '需用导入终态判断停止轮询')
assert.match(page, /importOutcomeText/, '需展示导入/删除进度文案')
})
test('test_task_254_dedupe_normal_repeated_operation_is_idempotent', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.ok(page.includes('新增导入'))
assert.ok(page.includes('新增导入'))
})
test('test_task_254_dedupe_boundary_empty_input', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /开始日期/, '筛选需含开始日期')
assert.match(page, /结束日期/, '筛选需含结束日期')
assert.match(page, /上传人|用户名/, '筛选需含用户名')
assert.match(page, /分组/, '筛选需含分组下拉')
assert.match(page, /国家/, '筛选需含国家下拉')
})
test('test_task_254_dedupe_boundary_single_item', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /开始日期不能晚于结束日期|晚于结束日期/, '导出需做日期区间校验')
assert.match(page, /确定删除总数据/, '行删除确认文案应对齐 admin')
})
test('test_task_254_dedupe_boundary_limit_or_missing_field', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /\.xlsx|\.xls|xlsx/, '导入需 Excel 文件类型提示')
assert.match(page, /mounted|onMounted/, '需首屏加载')
})
test('test_task_254_dedupe_invalid_input_rejected', () => {
// 异常:不得出现空操作按钮(无 @click 的页头主按钮)。
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
const headerButtons = page.match(/type="primary"[^>]*>([^<]*?)</g) ?? []
for (const btn of headerButtons) {
const label = btn.match(/>([^<]*?)</)?.[1] ?? ''
if (/导入|导出/.test(label)) {
assert.ok(page.includes(`openImport`), `导入类按钮必须绑定 @click (${label})`)
}
}
})
test('test_task_254_dedupe_dependency_failure_returns_actionable_message', () => {
// 依赖失败:api 模块须暴露单行更新/删除端点;导入/删除导入复用既有常量(防假接线)。
const api = readSource('src/pages/asin/dedupe-total-api.ts')
assert.match(api, /updateDedupeTotal|method: 'PUT'|\.put</, '需支持行编辑 PUT')
assert.match(api, /deleteDedupeTotal|\.delete</, '需支持行删除 DELETE')
const importApi = readSource('src/pages/asin/dedupe-import-api.ts')
assert.match(importApi, /DEDUPE_IMPORT_ENDPOINT/, '需复用导入端点常量')
assert.match(importApi, /DEDUPE_DELETE_IMPORT_ENDPOINT/, '需复用删除导入端点常量')
})