Files
order_site/apps/frontend/src/views/admin/AdminMessageDeliveriesView.vue
T
2026-04-23 14:46:06 +08:00

698 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { fetchAdminMessageDeliveries } from '@/services/admin'
import type { AdminMessageDeliveryListItem, AdminPagination } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { adminMessageDeliveryStatusOptions } from '@/utils/admin-options'
const loading = ref(true)
const errorMessage = ref('')
const items = ref<AdminMessageDeliveryListItem[]>([])
const provider = ref('agiso')
const platform = ref('xianyu')
const status = ref('')
const shopId = ref('')
const platformOrderId = ref('')
const taskNo = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const pagination = ref<AdminPagination>({
page: 1,
pageSize: 20,
total: 0,
})
const summary = computed(() => ({
successCount: items.value.filter((item) => item.status === 'success').length,
failedCount: items.value.filter((item) => item.status === 'failed').length,
pendingCount: items.value.filter((item) => item.status === 'pending').length,
}))
async function loadMessageDeliveries(page = pagination.value.page) {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminMessageDeliveries({
page,
pageSize: pagination.value.pageSize,
provider: provider.value.trim(),
platform: platform.value.trim(),
status: status.value.trim(),
shopId: shopId.value.trim(),
platformOrderId: platformOrderId.value.trim(),
taskNo: taskNo.value.trim(),
dateFrom: dateFrom.value.trim(),
dateTo: dateTo.value.trim(),
})
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取消息发送记录失败'
} finally {
loading.value = false
}
}
function resetFilters() {
provider.value = 'agiso'
platform.value = 'xianyu'
status.value = ''
shopId.value = ''
platformOrderId.value = ''
taskNo.value = ''
dateFrom.value = ''
dateTo.value = ''
void loadMessageDeliveries(1)
}
function formatResponse(item: AdminMessageDeliveryListItem) {
if (item.responseStatus <= 0 && !item.errorMessage) {
return '-'
}
const parts = []
if (item.responseStatus > 0) {
parts.push(`HTTP ${item.responseStatus}`)
}
const msg = String(item.response?.Error_Msg || item.response?.msg || item.response?.message || item.response?.error || '').trim()
if (msg) {
parts.push(msg)
}
return parts.join(' · ') || item.errorMessage || '-'
}
onMounted(loadMessageDeliveries)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div class="panel-header-copy">
<h1>消息发送记录</h1>
<p>查看领取链接消息是否真正发出、平台返回了什么、失败原因是什么。</p>
</div>
<div class="panel-header-badge">共 {{ pagination.total }} 条记录</div>
</header>
<section class="filters-card">
<div class="filters-grid">
<label class="filter-field">
<span>服务商</span>
<input v-model="provider" class="text-input" placeholder="如 agiso" />
</label>
<label class="filter-field">
<span>业务平台</span>
<input v-model="platform" class="text-input" placeholder="如 xianyu" />
</label>
<label class="filter-field">
<span>发送状态</span>
<select v-model="status" class="text-input select-input">
<option v-for="option in adminMessageDeliveryStatusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>店铺 ID</span>
<input v-model="shopId" class="text-input" placeholder="支持精确匹配" />
</label>
<label class="filter-field">
<span>平台订单号</span>
<input v-model="platformOrderId" class="text-input" placeholder="平台订单号" />
</label>
<label class="filter-field">
<span>任务号</span>
<input v-model="taskNo" class="text-input" placeholder="任务编号" />
</label>
<label class="filter-field">
<span>开始日期</span>
<input v-model="dateFrom" class="text-input" type="date" />
</label>
<label class="filter-field">
<span>结束日期</span>
<input v-model="dateTo" class="text-input" type="date" />
</label>
</div>
<div class="filters-actions">
<span class="filters-hint">消息体、Claim 链接、接口回执和失败原因都已经拆分展示,查问题会更直观。</span>
<div class="filters-button-row">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="() => loadMessageDeliveries(1)">查询</el-button>
</div>
</div>
</section>
<section v-if="!loading && items.length > 0" class="summary-grid">
<article class="summary-card summary-card-success">
<span class="summary-label">当前页成功</span>
<strong class="summary-value">{{ summary.successCount }}</strong>
<span class="summary-meta">消息已被平台受理或发送完成</span>
</article>
<article class="summary-card summary-card-danger">
<span class="summary-label">当前页失败</span>
<strong class="summary-value">{{ summary.failedCount }}</strong>
<span class="summary-meta">优先查看失败原因和接口返回</span>
</article>
<article class="summary-card summary-card-warning">
<span class="summary-label">当前页等待</span>
<strong class="summary-value">{{ summary.pendingCount }}</strong>
<span class="summary-meta">等待发送或等待外部回调更新</span>
</article>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">消息发送记录加载中</div>
<div v-else-if="items.length === 0" class="empty-block">当前筛选下还没有消息发送记录。</div>
<div v-else class="records-shell">
<div class="records-toolbar">
<div>
<strong>发送明细</strong>
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
</div>
</div>
<div class="record-list">
<article v-for="item in items" :key="item.deliveryId" class="record-card">
<div class="record-top">
<div class="record-identity">
<span class="record-id">#{{ item.deliveryId }}</span>
<div class="record-source-line">
<span class="source-pill">{{ item.provider || '-' }}</span>
<span class="source-separator">/</span>
<span class="source-pill">{{ item.platform || '-' }}</span>
<span class="source-chip">{{ item.shopName || item.shopId || '-' }}</span>
<span class="source-chip source-chip--muted">{{ item.channel || '-' }}</span>
</div>
</div>
<AdminStatusTag :status="item.status" />
</div>
<div class="record-grid">
<section class="record-section">
<span class="section-label">订单 / 任务</span>
<div class="cell-stack">
<div class="cell-title">{{ item.platformOrderId || '-' }}</div>
<div class="meta-chip-row">
<span v-if="item.orderId" class="meta-chip">
订单
<RouterLink class="inline-link" :to="`/admin/orders/${item.orderId}`">{{ item.orderId }}</RouterLink>
</span>
<span v-if="item.taskId" class="meta-chip">
任务
<RouterLink class="inline-link" :to="`/admin/tasks/${item.taskId}`">{{ item.taskNo || item.taskId }}</RouterLink>
</span>
</div>
<div v-if="item.taskStatus" class="tag-row">
<AdminStatusTag :status="item.taskStatus" />
</div>
</div>
</section>
<section class="record-section record-section-message">
<span class="section-label">消息内容</span>
<div class="message-cell" :title="item.messageContent">
{{ item.messageContent || '-' }}
</div>
<a v-if="item.claimUrl" class="claim-link claim-link-button" :href="item.claimUrl" target="_blank" rel="noreferrer">
打开 Claim 链接
</a>
</section>
<section class="record-section">
<span class="section-label">接口结果</span>
<div class="cell-stack">
<div class="cell-title">{{ formatResponse(item) }}</div>
<div class="cell-subtle">HTTP {{ item.responseStatus || 0 }}</div>
<div v-if="item.requestUrl" class="cell-subtle cell-subtle-break">{{ item.requestUrl }}</div>
<div v-if="item.errorMessage" class="error-inline">{{ item.errorMessage }}</div>
</div>
</section>
<section class="record-section">
<span class="section-label">时间</span>
<div class="cell-stack">
<div class="cell-title">创建 {{ formatAdminDateTime(item.createdAt) }}</div>
<div class="cell-subtle">发送 {{ formatAdminDateTime(item.sentAt) }}</div>
<div class="cell-subtle">更新 {{ formatAdminDateTime(item.updatedAt) }}</div>
</div>
</section>
</div>
</article>
</div>
<AdminPaginationBar
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:loading="loading"
@change="loadMessageDeliveries"
/>
</div>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 14px;
min-width: 0;
}
.panel-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.panel-header-copy {
min-width: 0;
}
.panel-header h1 {
margin: 0;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
color: #1d3555;
}
.panel-header p {
margin: 6px 0 0;
font-size: 14px;
color: #64748b;
}
.panel-header-badge {
flex: 0 0 auto;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #38506e;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
}
.filters-card,
.summary-card,
.records-shell,
.empty-block,
.error-copy {
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 14px 36px rgba(30, 49, 78, 0.06);
}
.filters-card {
display: grid;
gap: 14px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
}
.filter-field span {
color: #5d6f87;
font-size: 12px;
font-weight: 600;
}
.filters-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filters-hint {
color: #64748b;
font-size: 12px;
}
.filters-button-row {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
display: grid;
gap: 6px;
position: relative;
overflow: hidden;
}
.summary-card::after {
content: '';
position: absolute;
inset: auto -26px -26px auto;
width: 96px;
height: 96px;
border-radius: 999px;
opacity: 0.14;
}
.summary-card-success::after {
background: #12b76a;
}
.summary-card-danger::after {
background: #f04438;
}
.summary-card-warning::after {
background: #f79009;
}
.summary-label {
color: #51647d;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
.summary-value {
font-size: 34px;
line-height: 1;
color: #1d3555;
}
.summary-meta {
color: #70829a;
font-size: 12px;
}
.error-copy {
color: #b42318;
margin: 0;
}
.empty-block {
color: #64748b;
}
.text-input {
width: 100%;
min-height: 38px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.94);
color: #24364e;
font-size: 13px;
}
.select-input {
appearance: none;
}
.records-shell {
display: grid;
gap: 14px;
min-width: 0;
}
.records-toolbar strong {
display: block;
color: #1f324a;
font-size: 14px;
}
.records-toolbar span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.record-list {
display: grid;
gap: 12px;
}
.record-card {
display: grid;
gap: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98) 0%, #ffffff 100%);
border: 1px solid rgba(86, 108, 138, 0.12);
}
.record-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.record-identity {
display: grid;
gap: 8px;
min-width: 0;
}
.record-id {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #eef4ff;
color: #2456a2;
font-size: 12px;
font-weight: 800;
}
.record-source-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.source-pill,
.source-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
max-width: 100%;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.source-pill {
background: #f1f6ff;
color: #2456a2;
}
.source-chip {
background: #f4f7fb;
color: #44556c;
}
.source-chip--muted {
color: #6b7c93;
}
.source-separator {
color: #8b9cb2;
font-size: 12px;
font-weight: 700;
}
.record-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.record-section {
display: grid;
gap: 8px;
min-width: 0;
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(86, 108, 138, 0.08);
}
.record-section-message {
grid-column: span 2;
}
.section-label {
color: #71839b;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cell-stack {
display: grid;
gap: 6px;
min-width: 0;
}
.cell-title {
color: #1d3555;
font-weight: 700;
word-break: break-word;
}
.cell-subtle {
color: #64748b;
font-size: 12px;
}
.cell-subtle-break {
word-break: break-all;
}
.message-cell {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
color: #334155;
font-size: 13px;
min-height: 72px;
}
.claim-link,
.inline-link {
color: #175cd3;
text-decoration: none;
}
.claim-link-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: #eff6ff;
border: 1px solid rgba(23, 92, 211, 0.16);
font-size: 12px;
font-weight: 700;
}
.meta-chip-row,
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f5f8fc;
color: #304866;
font-size: 12px;
}
.error-inline {
color: #b42318;
line-height: 1.6;
font-size: 12px;
}
@media (max-width: 1400px) {
.filters-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.record-section-message {
grid-column: span 1;
}
}
@media (max-width: 980px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
.filters-grid,
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters-actions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.panel-header h1 {
font-size: 28px;
}
.filters-grid,
.summary-grid {
grid-template-columns: 1fr;
}
.filters-button-row {
width: 100%;
}
.filters-button-row :deep(.el-button) {
flex: 1 1 auto;
}
.record-top {
flex-direction: column;
align-items: flex-start;
}
}
</style>