698 lines
16 KiB
Vue
698 lines
16 KiB
Vue
<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>
|