align(去重汇总): 页顶数据权限分组汇总卡(超管全部分组·N个/组员chips组长·组员/未加入分组)、导出改流式下载+等待遮罩(正在生成导出文件/已等待N秒)、分组空值显示未分组(对齐 admin.js renderDedupeGroupSummary/showDedupeTotalDataExportWait)

This commit is contained in:
2026-09-05 23:21:50 +08:00
parent 603822a6c1
commit 0387dc4918
4 changed files with 255 additions and 37 deletions
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 扩充筛选(用户名/日期/分组)、行编辑/删除、新增/删除导入(轮询)、导出。 */
import { onMounted, reactive, ref } from 'vue'
/** 数据去重总数据(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,
@@ -10,7 +12,7 @@ import {
fetchDedupeTotalGroups,
type DedupeGroupOption,
} from './dedupe-total-api.ts'
import type { DedupeTotalItem } from './dedupe-total-model.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 {
@@ -23,6 +25,7 @@ import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './de
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)
@@ -31,6 +34,11 @@ 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)
@@ -46,6 +54,26 @@ 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
@@ -213,22 +241,42 @@ async function submitImport(): Promise<void> {
}
}
function doExport(): void {
async function doExport(): Promise<void> {
const { startDate, endDate } = filter
if (startDate && endDate && startDate > endDate) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
if (exporting.value) return
exporting.value = true
window.setTimeout(() => {
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 = toExportUrl(filter)
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
ElMessage.success('导出文件已开始下载')
}, 120)
}
}
onMounted(() => {
@@ -253,6 +301,24 @@ onMounted(() => {
</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">
@@ -299,7 +365,7 @@ onMounted(() => {
</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>
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
@@ -361,12 +427,36 @@ onMounted(() => {
</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; }
@@ -374,4 +464,37 @@ onMounted(() => {
.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>