2026-04-29-web-admin-dashboard-console-region-tab-scope-implementation.md 13 KB

Web Admin Dashboard Console Region Tab & Scope Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: In web-admin Console (/dashboard/console) add a top region tab bar and make all displayed statistics obey super_admin (all regions) vs admin (only permission scope, with “all” meaning sum of permitted regions). “Latest orders” are merged across regions and sorted by createAt descending.

Architecture: Add region tab UI + tab state in web-admin/src/views/dashboard/console/index.vue. Refactor data loading into “scoped” loaders that either:

  • call dashboard APIs once when scope is a single region or super_admin/all, or
  • call APIs for each permitted region and aggregate results for admin/all.

Tech Stack: Vue3 <script setup lang="ts">, Pinia useUserStore, Element Plus el-button, existing REST APIs in web-admin/src/api/business.ts, region list from web-admin/src/api/admin.ts (fetchActiveRegions).


Task 1: Add Region Tabs UI + Tab State

Files:

  • Modify: web-admin/src/views/dashboard/console/index.vue

Step 1: Add region tab bar in template

Insert this block immediately after the existing page-head section (right after the closing </div> of .page-head and before the alert bar):

<div class="region-tabs region-tab-strip">
  <div class="tab-button-group region-tab-group">
    <el-button
      class="region-tab-btn"
      :type="activeRegionTabKey === 'all' ? 'primary' : 'default'"
      @click="selectRegionTab('all')"
    >
      全部区域
    </el-button>
    <el-button
      v-for="r in allowedRegionList"
      :key="r.id"
      class="region-tab-btn"
      :type="activeRegionTabKey === r.id ? 'primary' : 'default'"
      @click="selectRegionTab(r.id)"
    >
      {{ r.name }}
    </el-button>
  </div>
</div>

Step 2: Add missing UI styles (scoped)

In <style lang="scss" scoped>, add under .console-container { ... }:

.tab-button-group {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 12px;
}

.region-tab-btn {
  min-width: 96px;
}

Step 3: Add tab state + region permissions computation

In <script setup lang="ts">, add these imports:

import { fetchActiveRegions } from '@/api/admin'
import { useUserStore } from '@/store/modules/user'

Then add the following state after existing dashboard ref:

const userStore = useUserStore()

const regionList = ref<any[]>([])
const activeRegionTabKey = ref<string>('all')

const userRole = computed(() => userStore.getUserInfo?.role)

const allowedRegionIds = computed<string[]>(() => {
  const info: any = userStore.getUserInfo
  if (info?.role === 'super_admin') return []
  const ids = Array.isArray(info?.regionIds) ? info.regionIds : info?.regionId ? [info.regionId] : []
  return ids.map((x: any) => String(x))
})

const allowedRegionList = computed(() => {
  if (userRole.value === 'super_admin') return regionList.value
  const allow = new Set(allowedRegionIds.value)
  return regionList.value.filter((r: any) => allow.has(String(r.id)))
})

Step 4: Add tab selection handler + region loader

Add these functions near existing loadDashboard/loadOrderTrend functions:

const selectRegionTab = (key: string) => {
  if (activeRegionTabKey.value === key) return
  activeRegionTabKey.value = key
  void loadConsoleData()
}

const loadRegions = async () => {
  const res = await fetchActiveRegions()
  regionList.value = Array.isArray(res) ? res : []
}

Step 5: Adjust onMounted entry

Replace the current onMounted(() => { ... load... }) with:

onMounted(() => {
  void loadRegions().then(() => loadConsoleData())
})

Task 2: Implement Scoped Data Loaders (single region vs all)

Files:

  • Modify: web-admin/src/views/dashboard/console/index.vue

Step 1: Add scope helpers

Add the following helper functions in <script setup>:

const isSuperAdmin = computed(() => userRole.value === 'super_admin')

const getSingleRegionId = () => {
  return activeRegionTabKey.value !== 'all' ? String(activeRegionTabKey.value) : undefined
}

const getMultiRegionIds = () => {
  return allowedRegionIds.value
}

Step 2: Refactor into one “loadConsoleData” orchestration

Create a top-level loader:

const loadConsoleData = async () => {
  loading.value = true
  try {
    await Promise.all([
      loadDashboardScoped(),
      loadOrderTrendScoped(),
      loadTodayRegionStatsScoped(),
      loadTodayClientStatsScoped()
    ])
  } finally {
    loading.value = false
  }
}

Step 3: Scoped dashboard loader with admin/all aggregation

Replace existing loadDashboard() with loadDashboardScoped():

const loadDashboardScoped = async () => {
  const singleRegionId = getSingleRegionId()

  // super_admin + all -> keep existing unscoped behavior
  if (isSuperAdmin.value && !singleRegionId) {
    dashboard.value = await fetchWorkbenchDashboard()
    return
  }

  // single region
  if (singleRegionId) {
    dashboard.value = await fetchWorkbenchDashboard({ regionId: singleRegionId })
    return
  }

  // admin + all -> aggregate over permission regions
  const regionIds = getMultiRegionIds()
  const results = await Promise.allSettled(regionIds.map((id) => fetchWorkbenchDashboard({ regionId: id })))
  const ok = results
    .filter((r): r is PromiseFulfilledResult<WorkbenchDashboardData> => r.status === 'fulfilled')
    .map((r) => r.value)

  const first = ok[0]
  const sum = (key: keyof WorkbenchDashboardData) =>
    ok.reduce((acc, d) => acc + Number((d as any)?.[key] ?? 0), 0)

  const mergedRecentOrders = ok.flatMap((d) => (Array.isArray(d.recentOrders) ? d.recentOrders : []))
  mergedRecentOrders.sort((a: any, b: any) => new Date(b.createAt || 0).getTime() - new Date(a.createAt || 0).getTime())

  const snapMap = new Map<string, { type: string; title: string; count: number; items: string[] }>()
  ok.forEach((d) => {
    ;(d.todoSnapshots || []).forEach((s) => {
      const k = String(s.type || s.title || '')
      if (!k) return
      const prev = snapMap.get(k)
      if (!prev) {
        snapMap.set(k, { type: s.type, title: s.title, count: Number(s.count ?? 0), items: Array.isArray(s.items) ? [...s.items] : [] })
      } else {
        prev.count += Number(s.count ?? 0)
        const merged = [...prev.items, ...(Array.isArray(s.items) ? s.items : [])]
        prev.items = Array.from(new Set(merged))
      }
    })
  })

  dashboard.value = {
    todayOrderCount: sum('todayOrderCount'),
    todayOrderAmount: sum('todayOrderAmount'),
    monthOrderCount: sum('monthOrderCount'),
    monthOrderAmount: sum('monthOrderAmount'),
    pendingDeliveryCount: sum('pendingDeliveryCount'),
    completedOrderCount: sum('completedOrderCount'),
    activePriceListCount: sum('activePriceListCount'),
    customerCount: sum('customerCount'),
    supplierCount: sum('supplierCount'),
    deliveryWarningCount: sum('deliveryWarningCount'),
    todayOrderCountHint: first?.todayOrderCountHint,
    todayOrderAmountHint: first?.todayOrderAmountHint,
    recentOrders: mergedRecentOrders,
    todoSnapshots: Array.from(snapMap.values())
  } as WorkbenchDashboardData
}

Step 4: Scoped order trend aggregation

Replace existing loadOrderTrend() with loadOrderTrendScoped():

const loadOrderTrendScoped = async () => {
  trendLoading.value = true
  try {
    const singleRegionId = getSingleRegionId()

    // super_admin + all -> unscoped
    if (isSuperAdmin.value && !singleRegionId) {
      const list = await fetchOrderTrend({ days: TREND_DAYS })
      orderTrend.value = Array.isArray(list) ? list : []
      return
    }

    // single region
    const regionIds = singleRegionId ? [singleRegionId] : getMultiRegionIds()
    const results = await Promise.allSettled(regionIds.map((id) => fetchOrderTrend({ regionId: id, days: TREND_DAYS })))
    const ok = results
      .filter((r): r is PromiseFulfilledResult<OrderTrendDayItem[]> => r.status === 'fulfilled')
      .map((r) => r.value)

    const base = ok.find((x) => Array.isArray(x) && x.length > 0) || []
    const baseDates = base.map((x) => x.date)

    const m = new Map<string, OrderTrendDayItem>()
    ok.forEach((list) => {
      ;(list || []).forEach((row) => {
        const k = String(row.date)
        const prev = m.get(k)
        if (!prev) {
          m.set(k, { date: row.date, orderCount: Number(row.orderCount ?? 0), orderAmount: Number(row.orderAmount ?? 0) })
        } else {
          prev.orderCount += Number(row.orderCount ?? 0)
          prev.orderAmount += Number(row.orderAmount ?? 0)
        }
      })
    })

    orderTrend.value = baseDates.map((d) => m.get(d)).filter(Boolean)
  } catch {
    orderTrend.value = []
  } finally {
    trendLoading.value = false
  }
}

Step 5: Scoped “today by region” aggregation

Replace existing loadTodayRegionStats() with loadTodayRegionStatsScoped():

const loadTodayRegionStatsScoped = async () => {
  regionTodayLoading.value = true
  try {
    const singleRegionId = getSingleRegionId()

    // super_admin + all -> unscoped
    if (isSuperAdmin.value && !singleRegionId) {
      const list = await fetchTodayOrdersByRegion()
      regionTodayList.value = Array.isArray(list) ? list : []
      return
    }

    const regionIds = singleRegionId ? [singleRegionId] : getMultiRegionIds()
    const results = await Promise.allSettled(regionIds.map((id) => fetchTodayOrdersByRegion({ regionId: id })))
    const ok = results
      .filter((r): r is PromiseFulfilledResult<any[]> => r.status === 'fulfilled')
      .map((r) => r.value)
    const flat = ok.flat()

    const m = new Map<string, TodayRegionOrderStatItem>()
    flat.forEach((it: any) => {
      const rid = String(it.regionId)
      const prev = m.get(rid)
      if (!prev) {
        m.set(rid, {
          regionId: rid,
          regionName: it.regionName || '',
          orderCount: Number(it.orderCount ?? 0),
          orderAmount: Number(it.orderAmount ?? 0)
        })
      } else {
        prev.orderCount += Number(it.orderCount ?? 0)
        prev.orderAmount += Number(it.orderAmount ?? 0)
      }
    })

    const orderIds = singleRegionId ? [singleRegionId] : getMultiRegionIds()
    regionTodayList.value = orderIds.map((id) => m.get(String(id))).filter(Boolean)
  } catch {
    regionTodayList.value = []
  } finally {
    regionTodayLoading.value = false
  }
}

Step 6: Scoped “today by client” aggregation

Replace existing loadTodayClientStats() with loadTodayClientStatsScoped():

const loadTodayClientStatsScoped = async () => {
  clientTodayLoading.value = true
  try {
    const singleRegionId = getSingleRegionId()

    // super_admin + all -> unscoped
    if (isSuperAdmin.value && !singleRegionId) {
      const list = await fetchTodayOrdersByClient({ limit: 30 })
      clientTodayList.value = Array.isArray(list) ? list : []
      return
    }

    const regionIds = singleRegionId ? [singleRegionId] : getMultiRegionIds()
    const limit = 30
    const results = await Promise.allSettled(regionIds.map((id) => fetchTodayOrdersByClient({ regionId: id, limit })))
    const ok = results
      .filter((r): r is PromiseFulfilledResult<any[]> => r.status === 'fulfilled')
      .map((r) => r.value)
    const flat = ok.flat()

    const m = new Map<string, TodayClientOrderStatItem>()
    flat.forEach((it: any) => {
      const cid = String(it.clientId)
      const prev = m.get(cid)
      if (!prev) {
        m.set(cid, {
          clientId: cid,
          clientName: it.clientName || '',
          orderAmount: Number(it.orderAmount ?? 0),
          productQuantity: Number(it.productQuantity ?? 0)
        })
      } else {
        prev.orderAmount += Number(it.orderAmount ?? 0)
        prev.productQuantity += Number(it.productQuantity ?? 0)
      }
    })

    const merged = Array.from(m.values())
    merged.sort((a, b) => (Number(b.orderAmount) || 0) - (Number(a.orderAmount) || 0))
    clientTodayList.value = merged
  } catch {
    clientTodayList.value = []
  } finally {
    clientTodayLoading.value = false
  }
}

Task 3: Ensure “Latest Orders” uses merged + createAt desc

Files:

  • Modify: web-admin/src/views/dashboard/console/index.vue

Step 1: Remove any implicit assumptions

Verify that the computed orderList remains:

const orderList = computed(() => dashboard.value?.recentOrders ?? [])

And ensure recentOrders is populated by loadDashboardScoped() aggregation, sorted by createAt descending:

mergedRecentOrders.sort((a: any, b: any) => new Date(b.createAt || 0).getTime() - new Date(a.createAt || 0).getTime())

No additional slicing or “top N” rule is applied beyond merging and sorting.


Task 4: Manual Verification

Files: none

Step 1: Super admin

  • 打开 http://localhost:3006/#/dashboard/console
  • 顶部出现区域 Tab:全部区域 + 每个启用区域
  • 切换任一单区 Tab,所有面板(概览卡片/趋势/今日各区域/今日下单客户/最新订单)应只显示该区数据
  • 回到 全部区域,所有统计回到全量数据

Step 2: 运营(admin)多区域

  • 切到一个具有多个 region 权限的运营账号
  • 顶部 Tab 仅展示其权限范围区域 + 全部区域
  • 切到权限某单区:展示该单区数据
  • 切到 全部区域:所有统计应等于“各权限区数据求和/合并”(最新订单按 createAt 倒序合并)