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>
|
||||
|
||||
@@ -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, /未分组/, '分组空值显示未分组')
|
||||
})
|
||||
Reference in New Issue
Block a user