@@ -0,0 +1,218 @@
< script setup lang = "ts" >
import '@/styles/adminDensity.css'
import { computed , onMounted , onUnmounted , ref , watch } from 'vue'
import { useRoute } 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 { 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 { RequestFence , RetryIntent , RuntimePoller , hasActiveTask , label , beijingTime , reasonsText , unitText , progressText , quantityText , taskRetryAllowed } from '../state'
const access = useAccessPreviewStore ( )
const route = useRoute ( )
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 rows = ref < Task [ ] > ( [ ] )
const current = ref ( 1 )
const size = ref ( 20 )
const total = ref ( 0 )
const upperBound = ref < { listUpperCreatedAt : string | null ; listUpperId : string | null } | null > ( null )
const observedAt = ref < string | null > ( null )
const error = ref ( '' )
const errorDetails = ref < ApiErrorDetails | null > ( null )
const loading = ref ( false )
const selected = ref < Task | null > ( null )
const selectedDetail = ref < TaskDetail | null > ( null )
const attempts = ref < Run [ ] > ( [ ] )
const attemptsPage = ref ( 1 )
const attemptsSize = ref ( 20 )
const attemptsTotal = ref ( 0 )
const attemptsUpperBound = ref < { listUpperCreatedAt : string | null ; listUpperId : string | null } | null > ( null )
const detailLoading = ref ( false )
const detailError = ref ( '' )
const reason = ref ( '' )
const confirming = ref ( false )
const submitting = ref ( false )
const actionFeedback = ref ( '' )
const fence = new RequestFence ( )
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 scope = computed ( ( ) => ( { sourceAccount , warehouseCode : warehouseCode . value } ) )
const retryAllowed = computed ( ( ) => taskRetryAllowed ( selected . value , canSync . value , scope . value , reason . value ) )
const columns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , width : 110 , fixed : 'left' , slotName : 'actions' } ,
{ prop : 'taskNo' , label : '任务号' , minWidth : 230 } ,
{ key : 'family' , label : '任务类型 / 用途' , minWidth : 160 , slotName : 'family' } ,
{ prop : 'warehouseCode' , label : '仓库' , minWidth : 120 } ,
{ key : 'range' , label : '目标范围(北京时间)' , minWidth : 280 , slotName : 'range' } ,
{ key : 'status' , label : '执行结果' , minWidth : 130 , slotName : 'status' } ,
{ key : 'publication' , label : '发布结果' , minWidth : 150 , slotName : 'publication' } ,
{ key : 'progress' , label : '阶段 / 可靠计数' , minWidth : 310 , slotName : 'progress' } ,
{ key : 'health' , label : '最近进展 / 心跳' , minWidth : 220 , slotName : 'health' } ,
{ key : 'block' , label : '阻断原因' , minWidth : 210 , slotName : 'block' } ,
]
const runColumns : DataTableColumn [ ] = [
{ prop : 'attemptNo' , label : '尝试序号' , width : 100 } ,
{ key : 'status' , label : '执行结果' , width : 120 , slotName : 'status' } ,
{ key : 'startedAt' , label : '开始(北京时间)' , minWidth : 180 , slotName : 'startedAt' } , { key : 'finishedAt' , label : '结束(北京时间)' , minWidth : 180 , slotName : 'finishedAt' } ,
{ key : 'progress' , label : '冻结阶段摘要' , minWidth : 290 , slotName : 'progress' } ,
{ prop : 'failureSummary' , label : '失败摘要' , minWidth : 210 } ,
]
function asTask ( row : unknown ) { return row as Task }
function asRun ( row : unknown ) { return row as Run }
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 ) : '—' }
function blockReason ( value : string | null | undefined ) { return value ? reasonsText ( [ value ] ) : '—' }
function budgetName ( key : string ) { return ( { pages : '分页HTTP' , detailHttp : '详情HTTP' , http : '全部HTTP保留额度' , confirmedHttp : '已确认真实HTTP' , distinctDetails : '不同详情对象' , maxPages : '分页HTTP上限' , maxPageSize : '每页数量上限' , maxDistinctDetails : '不同详情对象上限' , maxDetailHttp : '详情HTTP上限' , maxInventoryPagesPerWarehouse : '每仓库存页上限' , maxHttp : '全部HTTP上限' } as Record < string , string > ) [ key ] ? ? '其他保护额度' }
function limitFor ( used : string , value : Validation | null ) { const key = ( { pages : 'maxPages' , detailHttp : 'maxDetailHttp' , http : 'maxHttp' , confirmedHttp : 'maxHttp' , distinctDetails : 'maxDistinctDetails' } as Record < string , string > ) [ used ] ; return key ? value ? . budgetLimits ? . [ key ] : null }
async function read ( ) {
if ( ! canList . value ) return false
const request = fence . begin ( )
loading . value = ! overview . value
try {
const nextOverview = await analyticsApi . overview ( sourceAccount , undefined , request . signal )
const tasks = warehouseCode . value ? await analyticsApi . tasks ( { ... scope . value , current : current . value , size : size . value , ... upperBound . value } , request . signal ) : null
if ( ! request . current ( ) ) return false
overview . value = nextOverview
observedAt . value = nextOverview . observedAt
if ( tasks ) { rows . value = tasks . records ; total . value = tasks . total ; upperBound . value = { listUpperCreatedAt : tasks . listUpperCreatedAt , listUpperId : tasks . listUpperId } }
error . value = ''
errorDetails . value = null
return hasActiveTask ( [ ... rows . value , ... nextOverview . warehouses . flatMap ( row => [ row . currentTask , row . sharedUpstream ] . filter ( ( task ) : task is Task => Boolean ( task ) ) ) ] )
} catch ( cause ) { if ( request . current ( ) ) { errorDetails . value = getApiErrorDetails ( cause , '状态更新失败,保留最后读取数据' ) ; error . value = errorDetails . value . message } ; return hasActiveTask ( rows . value ) }
finally { if ( request . current ( ) ) loading . value = false }
}
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 = '' }
const request = detailFence . begin ( )
detailLoading . value = true
detailError . value = ''
try {
const detail = await analyticsApi . task ( scope . value , task . taskNo , request . signal )
const history = await analyticsApi . runs ( { ... scope . value , taskNo : task . taskNo , current : attemptsPage . value , size : attemptsSize . value , ... attemptsUpperBound . value } , request . signal )
if ( ! request . current ( ) ) return
if ( detail . task . taskNo !== task . taskNo || detail . task . warehouseCode !== warehouseCode . value ) throw new Error ( '任务归属不匹配' )
selected . value = detail . task
selectedDetail . value = detail
attempts . value = history . records
attemptsTotal . value = history . total
attemptsUpperBound . value = { listUpperCreatedAt : history . listUpperCreatedAt , listUpperId : history . listUpperId }
} catch ( cause ) { if ( request . current ( ) ) detailError . value = getApiErrorMessage ( cause , '任务详情更新失败,保留上次详情' ) }
finally { if ( request . current ( ) ) detailLoading . value = false }
}
async function submitRetry ( ) {
if ( ! confirming . value || ! retryAllowed . value || submitting . value || ! selected . value ) return
const target = selected . value
const request = actionFence . begin ( )
submitting . value = true
actionFeedback . value = ''
try {
const accepted = await analyticsApi . retry ( retryIntent . create ( target . taskNo , warehouseCode . value , reason . value ) , request . signal )
if ( ! request . current ( ) ) return
if ( accepted . taskNo !== target . taskNo ) throw new Error ( '受理任务号不匹配' )
retryIntent . accepted ( )
confirming . value = false
actionFeedback . value = '恢复请求已排队受理,尚未完成采集或发布。'
await read ( )
await selectTask ( target , false )
} catch ( cause ) { if ( request . current ( ) ) actionFeedback . value = getApiErrorMessage ( cause , '恢复请求失败;再次提交相同内容将沿用原幂等键' ) }
finally { if ( request . current ( ) ) submitting . value = false }
}
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 ( ) } )
watch ( [ current , size ] , ( ) => { void read ( ) } )
watch ( [ attemptsPage , attemptsSize ] , ( ) => { if ( selected . value ) void selectTask ( selected . value , false ) } )
watch ( reason , ( ) => { confirming . value = false } )
onMounted ( ( ) => { visibility ( ) ; poller . start ( ) ; document . addEventListener ( 'visibilitychange' , visibility ) } )
onUnmounted ( ( ) => { poller . stop ( ) ; actionFence . cancel ( ) ; document . removeEventListener ( 'visibilitychange' , visibility ) } )
< / script >
< template >
< 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 >
< 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 >
< template # status = "{ row }" > { { label ( asTask ( row ) . taskStatus ) } } < / template >
< template # publication = "{ row }" > { { label ( asTask ( row ) . resultPublicationState ) } } < / template >
< template # progress = "{ row }" > { { progressText ( asTask ( row ) . progress ) } } < / template >
< 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="选中任务详情" >
< 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 >
< p > 目标 { { beijingTime ( selected . queryFrom ) } } 至 { { beijingTime ( selected . queryUntil ) } } ; 下次重试 { { beijingTime ( selected . nextRetryAt ) } } ; { { selected . actionBlockReason ? blockReason ( selected . actionBlockReason ) : ( selected . latestAttempt ? . failureSummary ? ? '' ) } } < / p >
< p > 尝试开始 { { beijingTime ( selected . latestAttempt ? . startedAt ) } } ; 结束 { { beijingTime ( selected . latestAttempt ? . finishedAt ) } } ; 耗时 { { quantityText ( selected . latestAttempt ? . elapsedSeconds ) } } 秒 ; 心跳 { { beijingTime ( selected . latestAttempt ? . heartbeatAt ) } } · { { label ( selected . latestAttempt ? . executionHealth ) } } 。 < / p >
< p > 数据质量 : { { label ( selected . progress ? . validationState ) } } ; 异常 { { quantityText ( selected . progress ? . invalidCount ) } } , 未知 { { quantityText ( selected . progress ? . unknownCount ) } } , 待核对 { { quantityText ( selected . progress ? . pendingReviewCount ) } } ( { { unitText ( selected . progress ? . countUnit ) } } ) ; 摘要时间 { { beijingTime ( selected . progress ? . summaryAsOf ) } } 。 未计算的数量不默认为零 。 < / p >
< p v-for = "(stage, index) in selected.progress?.stages" :key="index" > {{ progressText ( stage ) }} < / p >
< p v-if = "selectedDetail?.sharedUpstream" > 账号共享上游 : {{ selectedDetail.sharedUpstream.taskNo }} · {{ label ( selectedDetail.sharedUpstream.taskStatus ) }} · {{ progressText ( selectedDetail.sharedUpstream.progress ) }} < / p >
< DataTable appearance = "integrated" v -model :page = "attemptsPage" v -model :page-size = "attemptsSize" :data = "attempts" :columns = "runColumns" :total = "attemptsTotal" :loading = "detailLoading" empty -text = " 尚无执行尝试 " >
< template # status = "{ row }" > { { label ( asRun ( row ) . status ) } } < / template > < template # progress = "{ row }" > { { progressText ( asRun ( row ) . progress ) } } < / template > < template # startedAt = "{ row }" > { { beijingTime ( asRun ( row ) . startedAt ) } } < / template > < template # finishedAt = "{ row }" > { { beijingTime ( asRun ( row ) . finishedAt ) } } < / template >
< / DataTable >
< form v-if = "canSync" class="density-form" @submit.prevent="submitRetry" >
< label for = "retry-reason" > 恢复原因 < / label > < el-input id = "retry-reason" v-model = "reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" / >
< p v-if = "!selected.allowedActions.includes('RETRY') || selected.actionBlockReason" > {{ selected.actionBlockReason ? blockReason ( selected.actionBlockReason ) : ' 当前任务不允许恢复 ' }} < / p >
< el -button v-if = "!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true" > 核对恢复请求 < / el -button >
< template v-else > < p > 确认恢复任务 { { selected . taskNo } } ( 仓库 { { selected . warehouseCode } } ) , 范围保持不变 , 原因 : { { reason } } 。 受理后仍须等待执行与发布 。 < / p > < el-button : disabled = "!retryAllowed || submitting" :loading = "submitting" native -type = " submit " type = "primary" > 确认提交恢复请求 < / el-button > < el-button :disabled = "submitting" @click ="confirming = false" > 返回修改 < / el -button > < / template >
< / form >
< p v-else > 没有任务恢复权限 。 < / p > < p v-if = "actionFeedback" role="status" > {{ actionFeedback }} < / p >
< / section >
< / ListPageLayout >
< / AdminPage >
< / template >