任务执行1-19

This commit is contained in:
zxr
2026-06-26 12:51:19 +08:00
parent 18531bfcac
commit cd5e8b5f2d
64 changed files with 7014 additions and 105 deletions

View File

@@ -8,6 +8,7 @@ import Chart from './chart/index.vue'
import SearchForm from './search-form/index.vue'
import DataTable from './data-table/index.vue'
import SearchTable from './search-table/index.vue'
import PageState from './page-state/index.vue'
// Manually introduce ECharts modules to reduce packing size
@@ -31,5 +32,6 @@ export default {
Vue.component('SearchForm', SearchForm)
Vue.component('DataTable', DataTable)
Vue.component('SearchTable', SearchTable)
Vue.component('PageState', PageState)
},
}

View File

@@ -8,7 +8,13 @@
<a-result v-if="!renderList.length" status="404">
<template #subtitle>{{ $t('messageBox.noContent') }}</template>
</a-result>
<List :render-list="renderList" :unread-count="unreadCount" @item-click="handleItemClick" />
<List
:render-list="renderList"
:unread-count="unreadCount"
@item-click="handleItemClick"
@resend="handleResend"
@view-history="openHistory"
/>
</a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
@@ -16,12 +22,33 @@
</a-button>
</template>
</a-tabs>
<a-drawer v-model:visible="historyVisible" width="760px" title="通知投递历史" :footer="false" unmount-on-close @open="fetchHistory">
<a-table :columns="historyColumns" :data="historyList" :loading="historyLoading" :pagination="historyPagination" row-key="id" @page-change="onHistoryPageChange">
<template #status="{ record }">
<a-tag :color="getDeliveryColor(record.status)">
{{ getDeliveryLabel(record.status) }}
</a-tag>
</template>
<template #operation="{ record }">
<a-button v-if="canResendDelivery(record.status)" size="mini" type="text" @click="resendDelivery(record.id)">补发</a-button>
</template>
</a-table>
</a-drawer>
</a-spin>
</template>
<script lang="ts" setup>
import { MessageListType, MessageRecord, queryMessageList, setMessageStatus } from '@/api/message'
import {
MessageListType,
MessageRecord,
NotificationDeliveryRecord,
queryMessageList,
queryNotificationDeliveryHistory,
resendNotificationDelivery,
setMessageStatus,
} from '@/api/message'
import useLoading from '@/hooks/loading'
import { Message } from '@arco-design/web-vue'
import { computed, reactive, ref, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import List from './list.vue'
@@ -42,6 +69,23 @@ const messageData = reactive<{
messageList: [],
})
toRefs(messageData)
const historyVisible = ref(false)
const historyLoading = ref(false)
const historyList = ref<NotificationDeliveryRecord[]>([])
const historyPagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const historyColumns = [
{ title: '渠道', dataIndex: 'channel_type', width: 90 },
{ title: '目标', dataIndex: 'target', ellipsis: true, tooltip: true },
{ title: '主题', dataIndex: 'subject', ellipsis: true, tooltip: true },
{ title: '状态', slotName: 'status', width: 90 },
{ title: '次数', dataIndex: 'attempt_count', width: 70 },
{ title: '错误原因', dataIndex: 'last_error', ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'operation', width: 80 },
]
const tabList: TabItem[] = [
{
key: 'message',
@@ -72,6 +116,59 @@ async function readMessage(data: MessageListType) {
await setMessageStatus({ ids })
fetchSourceData()
}
const fetchHistory = async () => {
historyLoading.value = true
try {
const res = await queryNotificationDeliveryHistory({
page: historyPagination.current,
page_size: historyPagination.pageSize,
})
const page = res.details || res.data
historyList.value = page?.data || []
historyPagination.total = page?.total || 0
} catch (err) {
Message.error('获取投递历史失败')
} finally {
historyLoading.value = false
}
}
const onHistoryPageChange = (page: number) => {
historyPagination.current = page
fetchHistory()
}
const openHistory = () => {
historyVisible.value = true
}
const canResendDelivery = (status: string) => {
return status === 'failed' || status === 'retry_scheduled'
}
const resendDelivery = async (id: number) => {
await resendNotificationDelivery(id)
Message.success('补发请求已提交')
fetchHistory()
fetchSourceData()
}
const handleResend = (item: MessageRecord) => {
resendDelivery(item.id)
}
const getDeliveryLabel = (status: string) => {
const map: Record<string, string> = {
pending: '待发送',
sent: '已发送',
failed: '失败',
retry_scheduled: '待重试',
}
return map[status] || status
}
const getDeliveryColor = (status: string) => {
const map: Record<string, string> = {
pending: 'gray',
sent: 'green',
failed: 'red',
retry_scheduled: 'orange',
}
return map[status] || 'gray'
}
const renderList = computed(() => {
return messageData.messageList.filter((item) => messageType.value === item.type)
})

View File

@@ -9,10 +9,16 @@
}"
>
<template #extra>
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
<a-space direction="vertical" :size="4" align="end">
<a-tag v-if="item.deliveryStatus" :color="getDeliveryColor(item.deliveryStatus)">
{{ getDeliveryLabel(item.deliveryStatus) }}
</a-tag>
<a-tag v-else-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
<a-button v-if="canResend(item)" size="mini" type="text" @click.stop="onResend(item)">补发</a-button>
</a-space>
</template>
<div class="item-wrap" @click="onItemClick(item)">
<a-list-item-meta>
@@ -53,7 +59,7 @@
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
</div>
<div class="footer-wrap">
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
<a-link @click="viewHistory">{{ $t('messageBox.viewMore') }}</a-link>
</div>
</a-space>
</template>
@@ -75,7 +81,7 @@ const props = defineProps({
default: 0,
},
})
const emit = defineEmits(['itemClick'])
const emit = defineEmits(['itemClick', 'resend', 'viewHistory'])
const allRead = () => {
emit('itemClick', [...props.renderList])
}
@@ -85,6 +91,33 @@ const onItemClick = (item: MessageRecord) => {
emit('itemClick', [item])
}
}
const canResend = (item: MessageRecord) => {
return item.deliveryStatus === 'failed' || item.deliveryStatus === 'retry_scheduled'
}
const onResend = (item: MessageRecord) => {
emit('resend', item)
}
const viewHistory = () => {
emit('viewHistory')
}
const getDeliveryLabel = (status: string) => {
const map: Record<string, string> = {
pending: '待发送',
sent: '已发送',
failed: '失败',
retry_scheduled: '待重试',
}
return map[status] || status
}
const getDeliveryColor = (status: string) => {
const map: Record<string, string> = {
pending: 'gray',
sent: 'green',
failed: 'red',
retry_scheduled: 'orange',
}
return map[status] || 'gray'
}
const showMax = 3
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="page-state" :class="[`page-state-${status}`]">
<a-spin v-if="status === 'loading'" :tip="loadingText" class="page-state-spin">
<slot />
</a-spin>
<a-result v-else-if="status === 'error'" status="500" :title="errorTitle" :subtitle="errorText">
<template #extra>
<a-button type="primary" @click="$emit('retry')">重试</a-button>
</template>
</a-result>
<a-result v-else-if="status === 'unauthorized'" status="403" :title="unauthorizedTitle" :subtitle="unauthorizedText" />
<template v-else>
<a-alert v-if="status === 'partial'" type="warning" show-icon class="page-state-alert">
{{ partialText }}
</a-alert>
<slot />
<a-empty v-if="status === 'empty'" :description="emptyText" class="page-state-empty" />
</template>
</div>
</template>
<script setup lang="ts">
defineProps({
status: {
type: String,
default: 'success',
validator: (value: string) => ['loading', 'empty', 'error', 'success', 'partial', 'unauthorized'].includes(value),
},
loadingText: {
type: String,
default: '加载中',
},
emptyText: {
type: String,
default: '暂无数据',
},
errorTitle: {
type: String,
default: '加载失败',
},
errorText: {
type: String,
default: '数据暂时不可用,请稍后重试。',
},
partialText: {
type: String,
default: '部分数据加载失败,当前页面展示可用数据。',
},
unauthorizedTitle: {
type: String,
default: '无权限访问',
},
unauthorizedText: {
type: String,
default: '当前账号没有访问或操作该页面的权限。',
},
})
defineEmits<{
(e: 'retry'): void
}>()
</script>
<script lang="ts">
export default {
name: 'PageState',
}
</script>
<style scoped lang="less">
.page-state {
min-width: 0;
}
.page-state-spin {
width: 100%;
}
.page-state-alert {
margin-bottom: 12px;
}
.page-state-empty {
padding: 32px 0;
}
</style>

View File

@@ -20,43 +20,53 @@
<a-divider style="margin-top: 0" />
<!-- 数据表格 -->
<DataTable
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="scroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
<PageState
:status="stateStatus"
:empty-text="emptyText"
:error-text="errorText"
:partial-text="partialText"
:loading-text="loadingText"
@retry="handleRefresh"
>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
<DataTable
v-if="stateStatus !== 'empty' && stateStatus !== 'error' && stateStatus !== 'unauthorized'"
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="responsiveScroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</PageState>
</a-card>
</div>
</template>
@@ -105,6 +115,26 @@ const props = defineProps({
type: Boolean,
default: false,
},
status: {
type: String as PropType<'loading' | 'empty' | 'error' | 'success' | 'partial' | 'unauthorized' | undefined>,
default: undefined,
},
loadingText: {
type: String,
default: '加载中',
},
emptyText: {
type: String,
default: '暂无数据',
},
errorText: {
type: String,
default: '数据暂时不可用,请稍后重试。',
},
partialText: {
type: String,
default: '部分数据加载失败,当前页面展示可用数据。',
},
pagination: {
type: Object as PropType<{
current: number
@@ -191,6 +221,17 @@ const slotColumns = computed(() => {
return props.columns.filter((col) => col.slotName)
})
const stateStatus = computed(() => {
if (props.status) return props.status
if (props.loading) return 'loading'
if (!props.data.length) return 'empty'
return 'success'
})
const responsiveScroll = computed(() => {
return props.scroll || { x: 'max-content' }
})
const handleFormModelUpdate = (value: Record<string, any>) => {
emit('update:formModel', value)
}
@@ -246,4 +287,10 @@ export default {
.search-table-container {
padding: 0 20px 20px 20px;
}
@media (max-width: 768px) {
.search-table-container {
padding: 0 12px 12px;
}
}
</style>