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>
@@ -2,7 +2,7 @@
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { normalizeAsinPageParams, toAsinPageQuery, type AsinListParams } from './asin-filter'
import { parseDedupeTotalPage, type DedupeTotalPageResult } from './dedupe-total-model'
import { parseDedupeTotalPage, parseDedupeGroupOptionList, type DedupeGroupOption, type DedupeTotalPageResult } from './dedupe-total-model'
export const DEDUPE_TOTAL_ENDPOINT = '/api/admin/dedupe-total-data'
/** 数据权限分组选项源(与店铺/去重共用同一分组集)。 */
@@ -14,10 +14,7 @@ export async function fetchDedupeTotalList(params: Partial<AsinListParams> = {})
return parseDedupeTotalPage(data)
}
export interface DedupeGroupOption {
id: number
name: string
}
export { type DedupeGroupOption }
/** 编辑单行去重总数据(ASIN 值 + 分组):PUT /dedupe-total-data/{id}。 */
export async function updateDedupeTotal(id: number, dataValue: string, groupId: number): Promise<void> {
@@ -34,30 +31,8 @@ export async function deleteDedupeTotal(id: number): Promise<void> {
unwrap<unknown>(data)
}
/** 加载数据权限分组选项:GET /shop-manage-groups → {id,name}[]。 */
/** 加载数据权限分组选项:GET /shop-manages/groups → {id,name,leaderUserId}[]。 */
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
}
@@ -74,3 +74,60 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
export function emptyDedupePageParams(): Partial<AsinListParams> {
return { page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
/** 数据权限分组选项(GET /api/admin/shop-manages/groups items)。 */
export interface DedupeGroupOption {
id: number
name: string
leaderUserId?: number | null
}
/** 归一化分组选项负载(信封 items 或裸数组),缺 id 行丢弃;解析组长 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() : ''
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
const leaderRaw = record.leader_user_id ?? record.leaderUserId
const leaderUserId = typeof leaderRaw === 'number' ? Math.floor(leaderRaw) : Number(leaderRaw)
if (Number.isFinite(leaderUserId) && leaderUserId > 0) option.leaderUserId = leaderUserId
options.push(option)
}
return options
}
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
export type DedupeGroupSummary =
| { kind: 'all'; count: number }
| { kind: 'none' }
| { kind: 'chips'; chips: Array<{ name: string; relation: string }>; extra: number }
export function dedupeGroupSummaryOf(
groups: DedupeGroupOption[],
currentUserId: number | null,
operatorRole: string,
): DedupeGroupSummary {
if ((operatorRole || '').toLowerCase() === 'super_admin') {
return { kind: 'all', count: groups.length }
}
if (!groups.length) return { kind: 'none' }
const visible = groups.slice(0, 4)
const currentKey = currentUserId != null ? String(currentUserId) : ''
const chips = visible.map((group) => ({
name: group.name,
relation: String(group.leaderUserId ?? '') === currentKey ? '组长' : '组员',
}))
return { kind: 'chips', chips, extra: Math.max(groups.length - visible.length, 0) }
}
@@ -0,0 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
dedupeGroupSummaryOf,
parseDedupeGroupOptionList,
type DedupeGroupOption,
} from '../src/pages/asin/dedupe-total-model.ts'
/** 对齐 admin.js:4355-4380 renderDedupeGroupSummary / 3362-3365 导出等待 / 3306 未分组。 */
function group(id: number, name: string, leaderUserId: number | null = null): DedupeGroupOption {
return { id, name, leaderUserId }
}
test('align_dedupe_summary_super_admin_shows_all_count', () => {
const summary = dedupeGroupSummaryOf([group(1, 'A组'), group(2, 'B组')], 99, 'super_admin')
assert.deepEqual(summary, { kind: 'all', count: 2 })
})
test('align_dedupe_summary_non_super_empty_shows_not_joined', () => {
const summary = dedupeGroupSummaryOf([], 7, 'admin')
assert.deepEqual(summary, { kind: 'none' })
})
test('align_dedupe_summary_chips_with_relation', () => {
const summary = dedupeGroupSummaryOf([group(1, 'A组', 7), group(2, 'B组', 9)], 7, 'admin')
assert.deepEqual(summary, {
kind: 'chips',
chips: [
{ name: 'A组', relation: '组长' },
{ name: 'B组', relation: '组员' },
],
extra: 0,
})
})
test('align_dedupe_summary_caps_at_four_with_extra', () => {
const groups = [1, 2, 3, 4, 5, 6].map((id) => group(id, `G${id}`, 9))
const summary = dedupeGroupSummaryOf(groups, 7, 'admin')
assert.equal(summary.kind, 'chips')
if (summary.kind === 'chips') {
assert.equal(summary.chips.length, 4)
assert.equal(summary.extra, 2)
}
})
test('align_dedupe_group_option_parses_leader', () => {
const options = parseDedupeGroupOptionList({
success: true,
data: { items: [{ id: 3, group_name: 'C组', leader_user_id: 12 }] },
})
assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }])
})
test('align_dedupe_page_summary_and_export_wait_wiring', () => {
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /数据权限分组/, '页顶数据权限分组汇总卡')
assert.match(page, /dedupeGroupSummaryOf/, '汇总走纯模型')
assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐')
assert.match(page, /已等待/, '导出等待含等待秒数')
assert.match(page, /未分组/, '分组空值显示未分组')
})