fix(selfop): unify collection layout and show details in dialogs
This commit is contained in:
@@ -8,6 +8,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
@@ -27,6 +28,10 @@ const overview = ref<Overview | null>(null)
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const rows = ref<(SalesSummary | Metric)[]>([])
|
||||
const daily = ref<DailyResponse | null>(null)
|
||||
const trendOpen = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const trendError = ref('')
|
||||
const trendErrorDetails = ref<ApiErrorDetails | null>(null)
|
||||
const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention)
|
||||
const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
|
||||
const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
|
||||
@@ -109,6 +114,7 @@ async function load(reset = false) {
|
||||
if (problem) { error.value = problem; return }
|
||||
}
|
||||
if (reset) { context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
trendOpen.value = false
|
||||
dailyFence.cancel()
|
||||
const request = dataFence.begin()
|
||||
loading.value = true
|
||||
@@ -129,8 +135,12 @@ async function load(reset = false) {
|
||||
finally { if (request.current()) loading.value = false }
|
||||
}
|
||||
async function showTrend(row: SalesSummary | Metric) {
|
||||
trendOpen.value = true
|
||||
trendLoading.value = true
|
||||
daily.value = null
|
||||
trendError.value = ''
|
||||
trendErrorDetails.value = null
|
||||
const request = dailyFence.begin()
|
||||
error.value = ''
|
||||
try {
|
||||
const params: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: row.customerCode, sku: row.sku, current: 1, size: 20, ...contextParams(context.value), rangePreset: props.mode === 'inventory' ? 'CUSTOM' : preset.value }
|
||||
if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) {
|
||||
@@ -141,7 +151,8 @@ async function showTrend(row: SalesSummary | Metric) {
|
||||
if (!request.current()) return
|
||||
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询')
|
||||
daily.value = result
|
||||
} catch (cause) { if (request.current()) error.value = getApiErrorMessage(cause, 'SKU趋势读取失败') }
|
||||
} catch (cause) { if (request.current()) { trendErrorDetails.value = getApiErrorDetails(cause, 'SKU趋势读取失败'); trendError.value = trendErrorDetails.value.message } }
|
||||
finally { if (request.current()) trendLoading.value = false }
|
||||
}
|
||||
async function readRuntime() {
|
||||
if (!permitted.value) return false
|
||||
@@ -159,6 +170,7 @@ async function readRuntime() {
|
||||
} catch (cause) { if (request.current()) runtimeError.value = getApiErrorMessage(cause, '状态更新失败,保留上次观测结果'); return false }
|
||||
}
|
||||
const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
|
||||
watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } })
|
||||
function visibility() { poller.setVisible(!document.hidden) }
|
||||
function search() {
|
||||
const rawThreshold = thresholdInput.value.trim()
|
||||
@@ -173,7 +185,7 @@ function search() {
|
||||
}
|
||||
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
|
||||
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
||||
watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
|
||||
watch(warehouseCode, () => { trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
|
||||
watch([preset, tab, windowDays], () => { current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() })
|
||||
watch([current, size], () => { void load() })
|
||||
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
|
||||
@@ -183,7 +195,9 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<AdminPage class="admin-density">
|
||||
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
||||
<ListPageLayout v-else>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #notices>
|
||||
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
|
||||
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
|
||||
@@ -195,7 +209,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
|
||||
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
|
||||
</details>
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
||||
</template>
|
||||
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button></template>
|
||||
<template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template>
|
||||
<template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
|
||||
@@ -208,11 +222,16 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<template #reason="{ row }">{{ reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asMetric(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
|
||||
</DataTable>
|
||||
<template v-if="daily && (summaryMode || mode === 'inventory')">
|
||||
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该SKU(全部货主)' : '在库存页查看该SKU(全部货主)' }}</el-button>
|
||||
<SkuTrend :data="daily" />
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
||||
<div class="admin-density">
|
||||
<LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError" compact><template #details><ApiErrorMeta v-if="trendErrorDetails" :details="trendErrorDetails" /></template></LoadErrorState>
|
||||
<template v-if="daily">
|
||||
<SkuTrend :data="daily" />
|
||||
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该SKU(全部货主)' : '在库存页查看该SKU(全部货主)' }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
# 自营分析逐操作矩阵
|
||||
|
||||
依据:2026-10-12 用户提供的销量/库存当前页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。截图仅证明修改前页面,修改后的视觉仍待人工 1920×1080、100% 截图。
|
||||
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。用户最新确认销量、库存样式已完成,本轮以其截图为数据采集统一样板;采集页修改后的视觉仍待人工1920×1080、100%截图。
|
||||
|
||||
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
|
||||
| /self-operated/sales | SKU 趋势 | 主页面内嵌单 SKU 日趋势,保留整个日轴,未知断线;跨页明确查看该SKU全部货主,不带隐藏货主筛选 | 不新建详情路由或浮层;待人工截图确认 |
|
||||
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
|
||||
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;本轮冻结为仓库、SKU、关注、阈值、排序同一筛选区;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;零与未知保持区分;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 每日趋势 | 方案“货主概览与库存关注”确认点击 SKU 沿 daily 接口同页下钻 | 点击 SKU 展示内嵌趋势,不新增操作列、菜单或浮层;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 每日趋势 | 与销量页统一居中趋势弹框;点击SKU仍沿daily接口读取本行货主/SKU | 不新增操作列或菜单;可关闭/全屏;待人工截图确认 |
|
||||
| 销量/库存 | 所属货主及同SKU趋势 | 删除独立货主选择、货主翻页和经营概览,不请求owners/ownerOverview;所属货主名称优先、编码可辅助 | 同SKU跨货主保持分行,趋势仍传本行customerCode;不改后端统计键 |
|
||||
| 销量/库存 | 规则说明及数据状态 | 当前截图:来源账号、技术时区和长段规则占主页面;本轮冻结为短说明和原生details折叠 | 主文案仅北京时间/件;未选仓库、暂无数据、查询失败分别显示;不新增浮层 |
|
||||
| /self-operated/collection | 每日库存观测积累 | 方案“每日库存观测留存”确认采集页显示近30完整日样本、缺口、起点 | 服务端结果直出,不计算周转率;待人工截图确认 |
|
||||
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 已确认主页面,执行/发布/覆盖独立展示 | 不提供整体百分比;待人工截图确认 |
|
||||
| /self-operated/collection | 任务详情和历史尝试 | 用户本轮确认采集主页面内嵌只读详情和历史尝试;总控已冻结 | 不新增弹框、抽屉、独立详情;待人工截图确认 |
|
||||
| /self-operated/collection | 恢复任务确认 | 用户本轮确认内嵌原因表单+显式确认步骤;RETRY 单一命令 | 不通过前端推断资格;受理不是采集成功;待人工截图确认 |
|
||||
| /self-operated/collection | 每日库存观测积累 | 本轮用户采集页截图确认长串缺口日期占用正文,要求优化;近30日样本数量保留主表,积累起点在配置弹框,缺口日期在弹框内折叠 | 服务端结果直出,不计算周转率;待人工截图确认 |
|
||||
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 本轮截图确认各仓20字段描述表重复展开;用户进一步确认“展示列表,配置项直接做成弹框”。主表集中仓库/类型/开关/执行/发布/覆盖/时效,各行查看配置打开只读AppDialog,保留预算、验证和覆盖字段 | 操作列首列;不新增配置写接口;关闭采集与已有结果分别表达,合成测试仓明确提示;待人工截图确认 |
|
||||
| /self-operated/collection | 任务详情和历史尝试 | 用户最新要求检查三页列表下方详情;改为1200px居中任务详情AppDialog,包含尝试历史 | 可关闭/全屏;保留任务业务号和分页;待人工截图确认 |
|
||||
| /self-operated/collection | 恢复任务确认 | 原因表单与显式确认步骤移入任务弹框,RETRY单一命令不变 | 提交中锁定关闭;独立sync权限、资格及幂等不变;待人工截图确认 |
|
||||
| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 |
|
||||
| 三页 | 公共搜索栏样式 | 用户提供异常订单公共搜索栏截图并要求自营统一;QueryToolbar放入集成DataTable的filters插槽 | 复用公共灰底筛选带、控件及查询/重置按钮,启用筛选布局设置;不复制CSS,不改查询和权限语义;待人工截图确认 |
|
||||
|
||||
自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。
|
||||
|
||||
## 数据采集样式统一(2026-10-12 当前截图确认)
|
||||
|
||||
用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 notices,顶部保留查看销量/库存的跨页入口,未选仓库时禁用;概览与任务表之间保留单一标准间距。沿用既有配置弹框、任务内嵌详情和恢复确认,不修改采集接口、资格或状态机。修改后待人工1920×1080、100%截图确认。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import type { Overview, WarehouseOverview } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), runs: vi.fn(), task: vi.fn(), retry: vi.fn(), push: vi.fn(), warehouse: '' }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: mocks.warehouse } }), useRouter: () => ({ push: mocks.push }) }))
|
||||
const pass = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { emit, slots }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'title', 'showConfirm'], emits: ['update:modelValue'], setup(props, { emit, slots }) {
|
||||
return () => props.modelValue ? h('section', { 'data-test': 'configuration-dialog' }, [h('h2', props.title), slots.default?.(), h('button', { onClick: () => emit('update:modelValue', false) }, '关闭')]) : null
|
||||
} })
|
||||
const table = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [
|
||||
h('div', { 'data-test': 'toolbar' }, slots['toolbar-actions']?.()), h('div', { 'data-test': 'filters' }, slots.filters?.()), h('div', { 'data-test': 'notices' }, slots.notices?.()),
|
||||
...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])),
|
||||
!(props.data ?? []).length ? props.emptyText : null,
|
||||
]) } })
|
||||
const setup = { global: { stubs: { AdminPage: pass, ListPageLayout: pass, QueryToolbar: true, DataTable: table, AppDialog: dialog,
|
||||
LoadErrorState: true, ApiErrorMeta: true, 'el-button': button, 'el-descriptions': pass, 'el-descriptions-item': pass, 'el-input': true } } }
|
||||
const stock: WarehouseOverview = {
|
||||
warehouseCode: 'SELFOP-TEST-A', warehouseName: '合成验收仓A', dataFamily: 'INVENTORY', executionEnabled: false, publicationEnabled: false,
|
||||
disabledReason: 'COLLECTION_OFF', nextScheduledAt: null, observedAt: '2026-10-12T13:00:00Z', initialFrom: null, initialUntil: null,
|
||||
initialCoverageStatus: 'COMPLETE', salesCoveredFrom: null, salesCoveredUntil: null, lastSuccessfulTaskNo: null, lastSucceededAt: null,
|
||||
lastPublishedAt: '2026-10-12T12:47:00Z', inventoryObservedFrom: '2026-10-12T12:40:00Z', inventoryObservedUntil: '2026-10-12T12:45:00Z',
|
||||
dataFreshness: 'VALID', rebuildRequired: false, revisionReviewStatus: 'CURRENT',
|
||||
protection: { accountCooldownUntil: null, accountPauseReason: null, dailyRequestBudget: 0, reservedHttpRequests: null, confirmedHttpRequests: null, budgetDate: null },
|
||||
inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 2, missingDates: ['2026-10-10'], accumulationStartedOn: '2026-10-09' },
|
||||
}
|
||||
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'test', collectionMode: 'OFF', observedAt: stock.observedAt,
|
||||
warehouses: [{ ...stock, dataFamily: 'OUTBOUND', inventoryDailySamples: null, salesCoveredUntil: stock.inventoryObservedUntil }, stock] }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.warehouse = ''; mocks.overview.mockResolvedValue(overview); mocks.tasks.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: null, listUpperId: null }) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
async function page() { const wrapper = mount(CollectionView, setup); wrappers.push(wrapper); await flushPromises(); return wrapper }
|
||||
describe('collection status list and per-row configuration dialog', () => {
|
||||
it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => {
|
||||
const wrapper = await page()
|
||||
expect(wrapper.findAllComponents(table)[0]?.props('data')).toHaveLength(2)
|
||||
expect(wrapper.findAllComponents(table)[0]?.props('columns')[0]).toMatchObject({ label: '操作', fixed: 'left', slotName: 'configuration' })
|
||||
expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果')
|
||||
expect(wrapper.text()).toContain('包含合成测试样本')
|
||||
expect(wrapper.text()).toContain('已留存 2 / 30 个日样本')
|
||||
expect(wrapper.text()).not.toContain('账号日预算')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false)
|
||||
expect(mocks.tasks).not.toHaveBeenCalled()
|
||||
const notices = wrapper.find('[data-test="notices"]')
|
||||
expect(notices.text()).toContain('自动采集已关闭')
|
||||
expect(notices.text()).toContain('包含合成测试样本')
|
||||
expect(notices.find('.collection-observed').text()).toContain('北京时间')
|
||||
const links = wrapper.find('[data-test="toolbar"]').findAll('button')
|
||||
expect(links.map(item => item.text())).toEqual(['查看销量', '查看库存'])
|
||||
expect(links.every(item => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
it('opens only the chosen warehouse family and keeps gaps folded, with no configuration write', async () => {
|
||||
const wrapper = await page()
|
||||
const buttons = wrapper.findAll('button').filter(item => item.text() === '查看配置')
|
||||
await buttons[1]?.trigger('click'); await flushPromises()
|
||||
const modal = wrapper.find('[data-test="configuration-dialog"]')
|
||||
expect(modal.text()).toContain('合成验收仓A · 产品库存配置')
|
||||
expect(modal.text()).toContain('账号日预算 0')
|
||||
expect(modal.text()).toContain('数据积累起点 2026-10-09')
|
||||
expect(modal.find('details').attributes('open')).toBeUndefined()
|
||||
expect(wrapper.findComponent(dialog).props('showConfirm')).toBe(false)
|
||||
await modal.findAll('button').find(item => item.text() === '关闭')?.trigger('click')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false)
|
||||
expect(mocks.retry).not.toHaveBeenCalled()
|
||||
expect(mocks.overview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves zero daily samples and an unknown accumulation date in the dialog', async () => {
|
||||
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, inventoryDailySamples: { ...stock.inventoryDailySamples, sampleCount: 0, accumulationStartedOn: null } }] })
|
||||
const wrapper = await page()
|
||||
expect(wrapper.text()).toContain('已留存 0 / 30 个日样本')
|
||||
await wrapper.findAll('button').find(item => item.text() === '查看配置')?.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').text()).toContain('数据积累起点 尚无日样本')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').text()).not.toContain('数据积累起点 2026-10-09')
|
||||
})
|
||||
it('navigates to the corresponding analysis with only the selected warehouse and makes no collection command', async () => {
|
||||
mocks.warehouse = 'SELFOP-TEST-A'
|
||||
const wrapper = await page()
|
||||
const toolbar = wrapper.find('[data-test="toolbar"]')
|
||||
await toolbar.findAll('button').find(item => item.text() === '查看库存')?.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { warehouseCode: 'SELFOP-TEST-A' } })
|
||||
await toolbar.findAll('button').find(item => item.text() === '查看销量')?.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/sales', query: { warehouseCode: 'SELFOP-TEST-A' } })
|
||||
expect(mocks.retry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
import type { Overview, Run, Task, TaskDetail, Validation } from '../types'
|
||||
import type { Overview, Run, Task, TaskDetail, Validation, WarehouseOverview } from '../types'
|
||||
import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, unitText, progressText, quantityText, taskRetryAllowed } from '../state'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const canList = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const canSync = computed(() => access.can('platform:self-operated-analytics:sync'))
|
||||
const sourceAccount = 'lingxing-primary'
|
||||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' })
|
||||
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
|
||||
const overview = ref<Overview | null>(null)
|
||||
const configOpen = ref(false)
|
||||
const configKey = ref('')
|
||||
const configItem = computed(() => overview.value?.warehouses.find(item => `${item.warehouseCode}:${item.dataFamily}` === configKey.value) ?? null)
|
||||
const rows = ref<Task[]>([])
|
||||
const current = ref(1)
|
||||
const size = ref(20)
|
||||
@@ -32,6 +37,7 @@ const error = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const selected = ref<Task | null>(null)
|
||||
const taskOpen = ref(false)
|
||||
const selectedDetail = ref<TaskDetail | null>(null)
|
||||
const attempts = ref<Run[]>([])
|
||||
const attemptsPage = ref(1)
|
||||
@@ -49,7 +55,19 @@ const detailFence = new RequestFence()
|
||||
const actionFence = new RequestFence()
|
||||
const retryIntent = new RetryIntent()
|
||||
const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? [])
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }])
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }])
|
||||
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
|
||||
const overviewColumns: DataTableColumn[] = [
|
||||
{ key: 'configuration', label: '操作', width: 110, fixed: 'left', slotName: 'configuration' },
|
||||
{ prop: 'warehouseName', label: '仓库', minWidth: 160, slotName: 'warehouse' },
|
||||
{ key: 'family', label: '数据类型', width: 110, slotName: 'family' },
|
||||
{ key: 'enabled', label: '自动采集', width: 100, slotName: 'enabled' },
|
||||
{ key: 'execution', label: '最近执行', width: 110, slotName: 'execution' },
|
||||
{ key: 'publication', label: '最近发布', minWidth: 180, slotName: 'publication' },
|
||||
{ key: 'coverage', label: '数据覆盖截至', minWidth: 180, slotName: 'coverage' },
|
||||
{ key: 'freshness', label: '数据时效', width: 100, slotName: 'freshness' },
|
||||
{ key: 'dailySamples', label: '库存日样本', minWidth: 150, slotName: 'dailySamples' },
|
||||
]
|
||||
const scope = computed(() => ({ sourceAccount, warehouseCode: warehouseCode.value }))
|
||||
const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value))
|
||||
const columns: DataTableColumn[] = [
|
||||
@@ -73,6 +91,8 @@ const runColumns: DataTableColumn[] = [
|
||||
]
|
||||
function asTask(row: unknown) { return row as Task }
|
||||
function asRun(row: unknown) { return row as Run }
|
||||
function asWarehouse(row: unknown) { return row as WarehouseOverview }
|
||||
function openConfiguration(item: WarehouseOverview) { configKey.value = `${item.warehouseCode}:${item.dataFamily}`; configOpen.value = true }
|
||||
function family(value: string) { return value === 'INVENTORY' ? '产品库存' : value === 'OUTBOUND' || value === 'SALES' ? '出库销量' : '任务类型待核对' }
|
||||
function validation(task: Task | null | undefined): Validation | null { return task?.validation ?? null }
|
||||
function budgetValue(value: unknown) { return typeof value === 'number' || typeof value === 'string' ? String(value) : '—' }
|
||||
@@ -98,7 +118,7 @@ async function read() {
|
||||
}
|
||||
async function selectTask(task: Task, reset = true) {
|
||||
if (!task.taskNo || task.warehouseCode !== warehouseCode.value) return
|
||||
if (reset) { actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
|
||||
if (reset) { taskOpen.value = true; actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
|
||||
const request = detailFence.begin()
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
@@ -136,7 +156,9 @@ async function submitRetry() {
|
||||
const poller = new RuntimePoller(async () => { const busy = await read(); if (selected.value && !submitting.value) await selectTask(selected.value, false); return busy }, () => { fence.cancel(); detailFence.cancel() })
|
||||
function visibility() { poller.setVisible(!document.hidden) }
|
||||
function refresh() { upperBound.value = null; current.value = 1; void read() }
|
||||
watch(warehouseCode, () => { fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() })
|
||||
function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value } }) }
|
||||
watch(warehouseCode, () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() })
|
||||
watch(taskOpen, open => { if (!open) { detailFence.cancel(); detailLoading.value = false; selected.value = null; selectedDetail.value = null; attempts.value = []; confirming.value = false; reason.value = ''; actionFeedback.value = '' } })
|
||||
watch([current, size], () => { void read() })
|
||||
watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) })
|
||||
watch(reason, () => { confirming.value = false })
|
||||
@@ -147,43 +169,31 @@ onUnmounted(() => { poller.stop(); actionFence.cancel(); document.removeEventLis
|
||||
<AdminPage class="admin-density">
|
||||
<p v-if="!canList" role="alert">没有采集状态查看权限。</p>
|
||||
<ListPageLayout v-else>
|
||||
<p>来源账号:{{ sourceAccount }} · 全部业务时间为北京时间 · 最近观测 {{ beijingTime(observedAt) }}</p>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" :loading="loading" @search="refresh" @reset="refresh" />
|
||||
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<section v-for="item in warehouses" :key="`${item.warehouseCode}:${item.dataFamily}`" :aria-label="`${item.warehouseName || item.warehouseCode}采集状态`">
|
||||
<h3>{{ item.warehouseName || item.warehouseCode }} · {{ family(item.dataFamily) }}</h3>
|
||||
<el-descriptions class="density-descriptions" :column="4" border>
|
||||
<el-descriptions-item label="环境 / 模式">{{ overview?.environmentLabel ?? '—' }} / {{ label(overview?.collectionMode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采集 / 发布">{{ item.executionEnabled ? '已启用' : '未启用' }} / {{ item.publicationEnabled ? '已启用' : '未启用' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ label(item.currentTask?.executionPurpose) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="停用原因">{{ blockReason(item.disabledReason) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近执行">{{ label(item.currentTask?.taskStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布结果">{{ label(item.currentTask?.resultPublicationState) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近成功">{{ item.lastSucceededAt ? beijingTime(item.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果发布时间">{{ item.lastPublishedAt ? beijingTime(item.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="两个月目标">{{ beijingTime(item.initialFrom) }} 至 {{ beijingTime(item.initialUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="首采完整覆盖">{{ label(item.initialCoverageStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(item.salesCoveredUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据新鲜度">{{ label(item.dataFreshness) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库存采集区间" :span="2">{{ beijingTime(item.inventoryObservedFrom) }} 至 {{ beijingTime(item.inventoryObservedUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下次计划">{{ item.nextScheduledAt ? beijingTime(item.nextScheduledAt) : (item.executionEnabled ? '待确认' : '未启用') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="修订状态">{{ item.rebuildRequired ? '待重建' : label(item.revisionReviewStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号冷却">{{ beijingTime(item.protection.accountCooldownUntil) }} {{ item.protection.accountPauseReason ?? '' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="验证会话">{{ validation(item.currentTask)?.validationSessionNo ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会话截止">{{ beijingTime(validation(item.currentTask)?.deadlineAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="停止原因">{{ validation(item.currentTask)?.stopReason ?? '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="item.dataFamily === 'INVENTORY' && item.inventoryDailySamples">每日库存观测:{{ item.inventoryDailySamples.windowFrom }} 至 {{ item.inventoryDailySamples.windowUntil }}(结束边界不含,近 {{ item.inventoryDailySamples.expectedDays }} 个完整日),已留存 {{ item.inventoryDailySamples.sampleCount }} / {{ item.inventoryDailySamples.expectedDays }} 个日样本;数据积累起点 {{ item.inventoryDailySamples.accumulationStartedOn ?? '尚无日样本' }}。缺口日期:{{ item.inventoryDailySamples.missingDates.length ? item.inventoryDailySamples.missingDates.join('、') : '无' }}。日样本只表示实际观测留存,不代表真实库龄。</p>
|
||||
<p v-else-if="item.dataFamily === 'INVENTORY'">每日库存观测积累状态尚无可用资料。</p>
|
||||
<p v-if="item.currentTask?.taskStatus === 'FAILED' || item.currentTask?.taskStatus === 'DEAD'">本次失败,{{ item.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}</p>
|
||||
<p v-if="item.sharedUpstream">账号共享阶段:{{ item.sharedUpstream.taskNo || '—' }} · {{ label(item.sharedUpstream.taskStatus) }} · {{ progressText(item.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。</p>
|
||||
<p>账号日预算 {{ quantityText(item.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(item.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(item.protection.confirmedHttpRequests) }};额度日期 {{ item.protection.budgetDate ?? '—' }}。</p>
|
||||
<p>验证会话预算(含保守预留):<span v-for="(used, key) in validation(item.currentTask)?.budgetConsumed" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(item.currentTask))) }}; </span><span v-if="!validation(item.currentTask)?.budgetConsumed">尚无预算观测</span></p>
|
||||
<p v-if="validation(item.currentTask)">会话限制:<span v-for="(limit, key) in validation(item.currentTask)?.budgetLimits" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; </span>最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。</p>
|
||||
</section>
|
||||
<p v-if="!warehouseCode">选择仓库后查看逻辑任务与尝试历史。首采完整覆盖不以单切片成功或账号目录完成推定。</p>
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" empty-text="暂无逻辑任务">
|
||||
<template #toolbar-actions><el-button @click="refresh">更新任务列表</el-button></template>
|
||||
<div class="collection-workspace">
|
||||
<DataTable appearance="integrated" :data="warehouses" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
|
||||
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/inventory')">查看库存</el-button></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<div class="collection-summary" role="status">
|
||||
<span v-if="overview?.collectionMode === 'OFF'">自动采集已关闭,当前仅查看已留存结果。</span>
|
||||
<span v-else>采集模式:{{ label(overview?.collectionMode) }}</span>
|
||||
<span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实领星采集覆盖。</span>
|
||||
<span class="collection-observed">最近更新:{{ beijingTime(observedAt) }}(北京时间)</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template>
|
||||
<template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}</template>
|
||||
<template #family="{ row }">{{ family(asWarehouse(row).dataFamily) }}</template>
|
||||
<template #enabled="{ row }">{{ asWarehouse(row).executionEnabled ? '已启用' : '已关闭' }}</template>
|
||||
<template #execution="{ row }">{{ label(asWarehouse(row).currentTask?.taskStatus) }}</template>
|
||||
<template #publication="{ row }">{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}<small class="collection-secondary">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}</small></template>
|
||||
<template #coverage="{ row }">{{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}</template>
|
||||
<template #freshness="{ row }">{{ label(asWarehouse(row).dataFreshness) }}</template>
|
||||
<template #dailySamples="{ row }"><template v-if="asWarehouse(row).dataFamily === 'INVENTORY' && asWarehouse(row).inventoryDailySamples">已留存 {{ asWarehouse(row).inventoryDailySamples?.sampleCount }} / {{ asWarehouse(row).inventoryDailySamples?.expectedDays }} 个日样本</template><template v-else>—</template></template>
|
||||
</DataTable>
|
||||
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :empty-text="!warehouseCode ? '选择仓库后查看采集任务与尝试历史' : error && !rows.length ? '任务读取失败,请重试' : '暂无采集任务'">
|
||||
<template #actions="{ row }"><el-button link :disabled="!asTask(row).taskNo" @click="selectTask(asTask(row))">详情 / 恢复</el-button></template>
|
||||
<template #family="{ row }">{{ family(asTask(row).dataFamily) }} / {{ label(asTask(row).executionPurpose) }}</template>
|
||||
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }} 至 {{ beijingTime(asTask(row).queryUntil) }}</template>
|
||||
@@ -193,7 +203,8 @@ onUnmounted(() => { poller.stop(); actionFence.cancel(); document.removeEventLis
|
||||
<template #health="{ row }">{{ beijingTime(asTask(row).latestAttempt?.progressUpdatedAt) }} / {{ label(asTask(row).latestAttempt?.executionHealth) }}</template>
|
||||
<template #block="{ row }">{{ blockReason(asTask(row).actionBlockReason) }}</template>
|
||||
</DataTable>
|
||||
<section v-if="selected" aria-label="选中任务详情">
|
||||
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
||||
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
|
||||
<h3>任务 {{ selected.taskNo || '—' }}</h3>
|
||||
<p v-if="detailLoading">读取任务详情…</p><p v-if="detailError" role="alert">{{ detailError }}</p>
|
||||
<p>执行:{{ label(selected.taskStatus) }};发布:{{ label(selected.resultPublicationState) }};尝试 {{ selected.attemptCount }} 次;{{ progressText(selected.progress) }}</p>
|
||||
@@ -213,6 +224,56 @@ onUnmounted(() => { poller.stop(); actionFence.cancel(); document.removeEventLis
|
||||
</form>
|
||||
<p v-else>没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="configOpen" class="admin-density-dialog" :title="`${configItem?.warehouseName || configItem?.warehouseCode || '仓库'} · ${family(configItem?.dataFamily || '')}配置`" width="1120px" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
||||
<div class="admin-density">
|
||||
<section v-if="configItem" class="collection-details">
|
||||
<h3>{{ configItem.warehouseName || configItem.warehouseCode }} · {{ family(configItem.dataFamily) }}</h3>
|
||||
<el-descriptions class="density-descriptions" :column="3" border>
|
||||
<el-descriptions-item label="环境 / 模式">{{ overview?.environmentLabel ?? '—' }} / {{ label(overview?.collectionMode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采集 / 发布">{{ configItem.executionEnabled ? '已启用' : '未启用' }} / {{ configItem.publicationEnabled ? '已启用' : '未启用' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ label(configItem.currentTask?.executionPurpose) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="停用原因">{{ blockReason(configItem.disabledReason) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近执行">{{ label(configItem.currentTask?.taskStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布结果">{{ label(configItem.currentTask?.resultPublicationState) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近成功">{{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果发布时间">{{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="两个月目标">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="首采完整覆盖">{{ label(configItem.initialCoverageStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(configItem.salesCoveredUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据新鲜度">{{ label(configItem.dataFreshness) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库存采集区间" :span="2">{{ beijingTime(configItem.inventoryObservedFrom) }} 至 {{ beijingTime(configItem.inventoryObservedUntil) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下次计划">{{ configItem.nextScheduledAt ? beijingTime(configItem.nextScheduledAt) : (configItem.executionEnabled ? '待确认' : '未启用') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="修订状态">{{ configItem.rebuildRequired ? '待重建' : label(configItem.revisionReviewStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号冷却">{{ beijingTime(configItem.protection.accountCooldownUntil) }} {{ configItem.protection.accountPauseReason ?? '' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="验证会话">{{ validation(configItem.currentTask)?.validationSessionNo ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会话截止">{{ beijingTime(validation(configItem.currentTask)?.deadlineAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="停止原因">{{ validation(configItem.currentTask)?.stopReason ?? '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template v-if="configItem.dataFamily === 'INVENTORY' && configItem.inventoryDailySamples">
|
||||
<p>每日库存观测:{{ configItem.inventoryDailySamples.windowFrom }} 至 {{ configItem.inventoryDailySamples.windowUntil }}(结束边界不含);数据积累起点 {{ configItem.inventoryDailySamples.accumulationStartedOn ?? '尚无日样本' }}。日样本只表示实际观测留存,不代表真实库龄。</p>
|
||||
<details class="collection-gaps"><summary>库存日样本缺口({{ configItem.inventoryDailySamples.missingDates.length }} 天)</summary><p>缺口日期:{{ configItem.inventoryDailySamples.missingDates.length ? configItem.inventoryDailySamples.missingDates.join('、') : '无' }}</p></details>
|
||||
</template>
|
||||
<p v-else-if="configItem.dataFamily === 'INVENTORY'">每日库存观测积累状态尚无可用资料。</p>
|
||||
<p v-if="configItem.currentTask?.taskStatus === 'FAILED' || configItem.currentTask?.taskStatus === 'DEAD'">本次失败,{{ configItem.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}</p>
|
||||
<p v-if="configItem.sharedUpstream">账号共享阶段:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ label(configItem.sharedUpstream.taskStatus) }} · {{ progressText(configItem.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。</p>
|
||||
<p>账号日预算 {{ quantityText(configItem.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(configItem.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(configItem.protection.confirmedHttpRequests) }};额度日期 {{ configItem.protection.budgetDate ?? '—' }}。</p>
|
||||
<p>验证会话预算(含保守预留):<span v-for="(used, key) in validation(configItem.currentTask)?.budgetConsumed" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(configItem.currentTask))) }}; </span><span v-if="!validation(configItem.currentTask)?.budgetConsumed">尚无预算观测</span></p>
|
||||
<p v-if="validation(configItem.currentTask)">会话限制:<span v-for="(limit, key) in validation(configItem.currentTask)?.budgetLimits" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; </span>最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。</p>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.collection-workspace { display: grid; min-width: 0; gap: var(--admin-space-6); }
|
||||
.collection-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-5); font-size: var(--admin-font-caption); }
|
||||
.collection-observed { margin-left: auto; color: var(--el-text-color-secondary); }
|
||||
.collection-secondary { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.collection-details summary, .collection-gaps summary { cursor: pointer; font-size: var(--admin-font-caption); }
|
||||
.collection-details h3 { font-size: var(--admin-font-section); margin: var(--admin-space-5) 0; }
|
||||
.collection-details p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.collection-details section + section { margin-top: var(--admin-space-5); }
|
||||
</style>
|
||||
|
||||
@@ -23,11 +23,12 @@ const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['up
|
||||
h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'),
|
||||
h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'),
|
||||
]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row })]))]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
|
||||
const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } })
|
||||
const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
|
||||
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
|
||||
const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } })
|
||||
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result }
|
||||
async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
|
||||
@@ -49,6 +50,17 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it.each(['sales', 'inventory', 'collection'] as const)('uses the public integrated filter region and layout settings for %s', async mode => {
|
||||
const wrapper = mode === 'collection' ? mount(CollectionView, options) : mount(AnalyticsView, { ...options, props: { mode } })
|
||||
wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
const table = wrapper.findComponent(tableStub)
|
||||
expect(table.vm.$slots.filters).toBeDefined()
|
||||
const toolbar = table.findComponent(queryStub)
|
||||
expect(toolbar.exists()).toBe(true)
|
||||
expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`)
|
||||
expect(wrapper.findAllComponents(queryStub)).toHaveLength(1)
|
||||
})
|
||||
it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
@@ -122,6 +134,7 @@ describe('self-operated actual workbench request state', () => {
|
||||
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已留存 0 / 30 个日样本')
|
||||
await findButton(wrapper, '查看配置').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('数据积累起点 尚无日样本')
|
||||
expect(wrapper.text()).toContain('缺口日期:2026-09-12')
|
||||
})
|
||||
@@ -167,11 +180,13 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(wrapper.text()).toContain('状态更新失败')
|
||||
expect(wrapper.text()).toContain('2026/10/12 09:00:00')
|
||||
})
|
||||
it('requires inline confirmation and preserves same key on ambiguous retry failure', async () => {
|
||||
it('requires confirmation inside the task dialog and preserves same key on ambiguous retry failure', async () => {
|
||||
mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
|
||||
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(dialog).find(item => item.props('modelValue'))?.props('title')).toContain('COL-A')
|
||||
await wrapper.find('textarea').setValue('已修复,恢复原任务')
|
||||
await findButton(wrapper, '核对恢复请求').trigger('click')
|
||||
expect(mocks.api.retry).not.toHaveBeenCalled()
|
||||
@@ -191,4 +206,19 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('没有任务恢复权限')
|
||||
})
|
||||
it.each(['sales', 'inventory'] as const)('opens %s daily details in a dialog and discards a closed pending response', async mode => {
|
||||
let resolveTrend!: (value: object) => void
|
||||
mocks.api.daily.mockImplementationOnce(() => new Promise(resolve => { resolveTrend = resolve }))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(false)
|
||||
await findButton(wrapper, mode === 'sales' ? '每日趋势' : 'SKU-A').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(dialog).props('title')).toBe('SKU每日出库趋势')
|
||||
wrapper.findComponent(dialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(mocks.api.daily.mock.calls.at(-1)?.[1].aborted).toBe(true)
|
||||
resolveTrend({ context, summary, points: [] }); await flushPromises()
|
||||
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user