align(去重汇总): 页顶数据权限分组汇总卡(超管全部分组·N个/组员chips组长·组员/未加入分组)、导出改流式下载+等待遮罩(正在生成导出文件/已等待N秒)、分组空值显示未分组(对齐 admin.js renderDedupeGroupSummary/showDedupeTotalDataExportWait)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user