Commit 8e93aa70c1e5120a398d3d1838b780ee1cdc035b

Authored by shaofan
1 parent 57265fcd

Refactor: 优化开放应用 Webhook 配置与日志管理,新增事件订阅选择、事件标签展示、日志分页及详情查看能力。

Showing 1 changed file with 187 additions and 19 deletions
src/views/open/OpenAppList.vue
@@ -19,6 +19,17 @@ @@ -19,6 +19,17 @@
19 <template v-if="column.key === 'cityId'"> 19 <template v-if="column.key === 'cityId'">
20 {{ getCityName(record.cityId) }} 20 {{ getCityName(record.cityId) }}
21 </template> 21 </template>
  22 + <template v-if="column.key === 'webhookEvents'">
  23 + <template v-if="parseWebhookEvents(record.webhookEvents).includes('*')">
  24 + <a-tag color="blue">全部事件</a-tag>
  25 + </template>
  26 + <template v-else-if="parseWebhookEvents(record.webhookEvents).length">
  27 + <a-tag v-for="event in parseWebhookEvents(record.webhookEvents)" :key="event">
  28 + {{ getEventLabel(event) }}
  29 + </a-tag>
  30 + </template>
  31 + <span v-else class="muted-text">未订阅</span>
  32 + </template>
22 <template v-if="column.key === 'action'"> 33 <template v-if="column.key === 'action'">
23 <a-space> 34 <a-space>
24 <a @click="handleResetSecret(record)">重置密钥</a> 35 <a @click="handleResetSecret(record)">重置密钥</a>
@@ -65,57 +76,107 @@ @@ -65,57 +76,107 @@
65 <a-form-item label="回调地址"> 76 <a-form-item label="回调地址">
66 <a-input v-model:value="webhookForm.webhookUrl" placeholder="https://your-server.com/webhook" /> 77 <a-input v-model:value="webhookForm.webhookUrl" placeholder="https://your-server.com/webhook" />
67 </a-form-item> 78 </a-form-item>
68 - <a-form-item label="订阅事件(JSON数组)">  
69 - <a-textarea 79 + <a-form-item label="订阅事件">
  80 + <a-checkbox v-model:checked="webhookForm.subscribeAll">订阅全部事件</a-checkbox>
  81 + <a-checkbox-group
70 v-model:value="webhookForm.webhookEvents" 82 v-model:value="webhookForm.webhookEvents"
71 - placeholder='["order.paid","order.completed","order.cancelled"]'  
72 - :rows="4"  
73 - /> 83 + :disabled="webhookForm.subscribeAll"
  84 + class="webhook-event-group"
  85 + >
  86 + <div v-for="item in webhookEventOptions" :key="item.value" class="webhook-event-option">
  87 + <a-checkbox :value="item.value">
  88 + <strong>{{ item.label }}</strong>
  89 + <a-tag class="event-code">{{ item.value }}</a-tag>
  90 + <div class="event-desc">{{ item.description }}</div>
  91 + </a-checkbox>
  92 + </div>
  93 + </a-checkbox-group>
74 </a-form-item> 94 </a-form-item>
75 - <a-alert message="支持事件:order.paid / order.accepted / order.completed / order.cancelled / order.refund" type="info" show-icon /> 95 + <a-alert message="未选择事件时不会主动推送;订单级 callbackUrl 仍会复用这里的订阅事件配置。" type="info" show-icon />
76 </a-form> 96 </a-form>
77 </div> 97 </div>
78 </a-modal> 98 </a-modal>
79 99
80 - <a-modal v-model:open="logsVisible" title="Webhook推送日志" :footer="null" width="800px"> 100 + <a-modal v-model:open="logsVisible" title="Webhook推送日志" :footer="null" width="1000px">
81 <div class="soft-page-stack"> 101 <div class="soft-page-stack">
82 <div class="soft-note-card"> 102 <div class="soft-note-card">
83 <strong>推送日志</strong> 103 <strong>推送日志</strong>
84 <p>失败记录支持手动重试,便于排查接入方回调地址或签名处理异常。</p> 104 <p>失败记录支持手动重试,便于排查接入方回调地址或签名处理异常。</p>
85 </div> 105 </div>
86 - <a-table :dataSource="logs" :columns="logColumns" rowKey="id" size="small" :pagination="false"> 106 + <a-table :dataSource="logs" :columns="logColumns" :loading="logLoading" rowKey="id" size="small" :pagination="false">
87 <template #bodyCell="{ column, record }"> 107 <template #bodyCell="{ column, record }">
  108 + <template v-if="column.key === 'event'">
  109 + {{ getEventLabel(record.event) }}
  110 + <div class="muted-text">{{ record.event }}</div>
  111 + </template>
  112 + <template v-if="column.key === 'url'">
  113 + <a-typography-text :ellipsis="{ tooltip: record.url }">{{ record.url }}</a-typography-text>
  114 + </template>
88 <template v-if="column.key === 'status'"> 115 <template v-if="column.key === 'status'">
89 <a-tag :color="record.status === 1 ? 'green' : 'red'"> 116 <a-tag :color="record.status === 1 ? 'green' : 'red'">
90 {{ record.status === 1 ? '成功' : '失败' }} 117 {{ record.status === 1 ? '成功' : '失败' }}
91 </a-tag> 118 </a-tag>
92 </template> 119 </template>
  120 + <template v-if="column.key === 'createTime'">
  121 + {{ formatTime(record.createTime) }}
  122 + </template>
93 <template v-if="column.key === 'action'"> 123 <template v-if="column.key === 'action'">
94 - <a v-if="record.status === 0" @click="retryLog(record.id)">重试</a> 124 + <a-space>
  125 + <a @click="showLogDetail(record, 'payload')">Payload</a>
  126 + <a @click="showLogDetail(record, 'response')">响应</a>
  127 + <a v-if="record.status === 0" @click="retryLog(record.id)">重试</a>
  128 + </a-space>
95 </template> 129 </template>
96 </template> 130 </template>
97 </a-table> 131 </a-table>
  132 + <div class="log-pagination">
  133 + <a-space>
  134 + <a-button :disabled="logPage <= 1 || logLoading" @click="loadLogs(logPage - 1)">上一页</a-button>
  135 + <span>第 {{ logPage }} 页</span>
  136 + <a-button :disabled="logs.length < 20 || logLoading" @click="loadLogs(logPage + 1)">下一页</a-button>
  137 + </a-space>
  138 + </div>
98 </div> 139 </div>
99 </a-modal> 140 </a-modal>
100 </div> 141 </div>
101 </template> 142 </template>
102 143
103 <script setup lang="ts"> 144 <script setup lang="ts">
104 -import { ref, reactive, onMounted } from 'vue' 145 +import { h, ref, reactive, onMounted } from 'vue'
105 import { message, Modal } from 'ant-design-vue' 146 import { message, Modal } from 'ant-design-vue'
106 import { openApi } from '@/api' 147 import { openApi } from '@/api'
107 import { useRoleCityList } from '@/composables/useRoleCityList' 148 import { useRoleCityList } from '@/composables/useRoleCityList'
108 149
  150 +type WebhookEventOption = {
  151 + label: string
  152 + value: string
  153 + description: string
  154 +}
  155 +
  156 +const defaultWebhookEventOptions: WebhookEventOption[] = [
  157 + { label: '订单创建', value: 'order.created', description: '外部系统推单成功,订单进入待接单' },
  158 + { label: '已派单', value: 'order.dispatched', description: '系统自动派单或后台指派骑手' },
  159 + { label: '骑手接单', value: 'order.accepted', description: '骑手主动抢单成功' },
  160 + { label: '骑手到店', value: 'order.arrived_shop', description: '骑手到达取货点' },
  161 + { label: '骑手取件', value: 'order.picking', description: '骑手取件并开始配送' },
  162 + { label: '订单完成', value: 'order.completed', description: '订单配送完成' },
  163 + { label: '订单取消', value: 'order.cancelled', description: '订单未接单前取消' },
  164 + { label: '订单转单', value: 'order.transferred', description: '转单审核通过,订单重新待接单' },
  165 +]
  166 +
109 const loading = ref(false) 167 const loading = ref(false)
  168 +const logLoading = ref(false)
110 const saving = ref(false) 169 const saving = ref(false)
111 const list = ref<any[]>([]) 170 const list = ref<any[]>([])
112 const logs = ref<any[]>([]) 171 const logs = ref<any[]>([])
  172 +const logPage = ref(1)
113 const addVisible = ref(false) 173 const addVisible = ref(false)
114 const webhookVisible = ref(false) 174 const webhookVisible = ref(false)
115 const logsVisible = ref(false) 175 const logsVisible = ref(false)
116 const currentAppId = ref(0) 176 const currentAppId = ref(0)
117 const addForm = reactive({ appName: '', cityId: undefined as number | undefined, remark: '' }) 177 const addForm = reactive({ appName: '', cityId: undefined as number | undefined, remark: '' })
118 -const webhookForm = reactive({ webhookUrl: '', webhookEvents: '' }) 178 +const webhookForm = reactive({ webhookUrl: '', webhookEvents: [] as string[], subscribeAll: false })
  179 +const webhookEventOptions = ref<WebhookEventOption[]>(defaultWebhookEventOptions)
119 const { cityList, loadCities, getCityName } = useRoleCityList() 180 const { cityList, loadCities, getCityName } = useRoleCityList()
120 181
121 const columns = [ 182 const columns = [
@@ -125,19 +186,64 @@ const columns = [ @@ -125,19 +186,64 @@ const columns = [
125 { title: '租户', key: 'cityId' }, 186 { title: '租户', key: 'cityId' },
126 { title: '状态', key: 'status' }, 187 { title: '状态', key: 'status' },
127 { title: '回调地址', dataIndex: 'webhookUrl', ellipsis: true }, 188 { title: '回调地址', dataIndex: 'webhookUrl', ellipsis: true },
  189 + { title: '订阅事件', key: 'webhookEvents' },
128 { title: '操作', key: 'action' }, 190 { title: '操作', key: 'action' },
129 ] 191 ]
130 192
131 const logColumns = [ 193 const logColumns = [
132 { title: 'ID', dataIndex: 'id', width: 80 }, 194 { title: 'ID', dataIndex: 'id', width: 80 },
133 - { title: '事件', dataIndex: 'event' }, 195 + { title: '事件', key: 'event' },
134 { title: '业务ID', dataIndex: 'bizId' }, 196 { title: '业务ID', dataIndex: 'bizId' },
  197 + { title: 'URL', key: 'url', width: 220 },
135 { title: '响应码', dataIndex: 'responseCode' }, 198 { title: '响应码', dataIndex: 'responseCode' },
136 { title: '状态', key: 'status' }, 199 { title: '状态', key: 'status' },
137 { title: '重试', dataIndex: 'retryCount' }, 200 { title: '重试', dataIndex: 'retryCount' },
  201 + { title: '时间', key: 'createTime' },
138 { title: '操作', key: 'action' }, 202 { title: '操作', key: 'action' },
139 ] 203 ]
140 204
  205 +function parseWebhookEvents(raw?: string): string[] {
  206 + if (!raw) return []
  207 + try {
  208 + const events = JSON.parse(raw)
  209 + return Array.isArray(events) ? events.filter(item => typeof item === 'string') : []
  210 + } catch {
  211 + return []
  212 + }
  213 +}
  214 +
  215 +function getEventLabel(event: string) {
  216 + if (event === '*') return '全部事件'
  217 + const option = webhookEventOptions.value.find(item => item.value === event)
  218 + return option ? option.label : event
  219 +}
  220 +
  221 +function formatTime(epochSecond?: number) {
  222 + if (!epochSecond) return '-'
  223 + return new Date(epochSecond * 1000).toLocaleString()
  224 +}
  225 +
  226 +function formatJsonText(value: unknown) {
  227 + if (!value) return ''
  228 + if (typeof value !== 'string') return String(value)
  229 + try {
  230 + return JSON.stringify(JSON.parse(value), null, 2)
  231 + } catch {
  232 + return value
  233 + }
  234 +}
  235 +
  236 +async function loadWebhookEvents() {
  237 + try {
  238 + const res: any = await openApi.webhookEvents()
  239 + if (Array.isArray(res.data) && res.data.length) {
  240 + webhookEventOptions.value = res.data
  241 + }
  242 + } catch {
  243 + webhookEventOptions.value = defaultWebhookEventOptions
  244 + }
  245 +}
  246 +
141 async function loadList() { 247 async function loadList() {
142 loading.value = true 248 loading.value = true
143 try { 249 try {
@@ -182,14 +288,17 @@ async function toggleStatus(record: any) { @@ -182,14 +288,17 @@ async function toggleStatus(record: any) {
182 function openWebhook(record: any) { 288 function openWebhook(record: any) {
183 currentAppId.value = record.id 289 currentAppId.value = record.id
184 webhookForm.webhookUrl = record.webhookUrl || '' 290 webhookForm.webhookUrl = record.webhookUrl || ''
185 - webhookForm.webhookEvents = record.webhookEvents || '' 291 + const events = parseWebhookEvents(record.webhookEvents)
  292 + webhookForm.subscribeAll = events.includes('*')
  293 + webhookForm.webhookEvents = events.filter(event => event !== '*')
186 webhookVisible.value = true 294 webhookVisible.value = true
187 } 295 }
188 296
189 async function handleWebhookSave() { 297 async function handleWebhookSave() {
  298 + const events = webhookForm.subscribeAll ? ['*'] : webhookForm.webhookEvents
190 saving.value = true 299 saving.value = true
191 try { 300 try {
192 - await openApi.updateWebhook(currentAppId.value, webhookForm.webhookUrl, webhookForm.webhookEvents) 301 + await openApi.updateWebhook(currentAppId.value, webhookForm.webhookUrl, JSON.stringify(events))
193 message.success('保存成功') 302 message.success('保存成功')
194 webhookVisible.value = false 303 webhookVisible.value = false
195 loadList() 304 loadList()
@@ -198,17 +307,76 @@ async function handleWebhookSave() { @@ -198,17 +307,76 @@ async function handleWebhookSave() {
198 307
199 async function openLogs(record: any) { 308 async function openLogs(record: any) {
200 currentAppId.value = record.id 309 currentAppId.value = record.id
201 - const res: any = await openApi.webhookLogs(record.id)  
202 - logs.value = res.data  
203 logsVisible.value = true 310 logsVisible.value = true
  311 + await loadLogs(1)
  312 +}
  313 +
  314 +async function loadLogs(page = 1) {
  315 + logLoading.value = true
  316 + try {
  317 + const res: any = await openApi.webhookLogs(currentAppId.value, page)
  318 + logs.value = Array.isArray(res.data) ? res.data : []
  319 + logPage.value = page
  320 + } finally { logLoading.value = false }
204 } 321 }
205 322
206 async function retryLog(logId: number) { 323 async function retryLog(logId: number) {
207 await openApi.retryWebhook(logId) 324 await openApi.retryWebhook(logId)
208 message.success('重试已触发') 325 message.success('重试已触发')
209 - const res: any = await openApi.webhookLogs(currentAppId.value)  
210 - logs.value = res.data 326 + await loadLogs(logPage.value)
211 } 327 }
212 328
213 -onMounted(() => { loadList(); loadCities() }) 329 +function showLogDetail(record: any, type: 'payload' | 'response') {
  330 + const title = type === 'payload' ? 'Webhook Payload' : 'Webhook 响应内容'
  331 + const content = type === 'payload' ? formatJsonText(record.payload) : (record.responseBody || '')
  332 + Modal.info({
  333 + title,
  334 + width: 720,
  335 + content: h('pre', { class: 'webhook-log-detail' }, content || '-'),
  336 + })
  337 +}
  338 +
  339 +onMounted(() => { loadWebhookEvents(); loadList(); loadCities() })
214 </script> 340 </script>
  341 +
  342 +<style scoped>
  343 +.muted-text {
  344 + color: #999;
  345 + font-size: 12px;
  346 +}
  347 +
  348 +.webhook-event-group {
  349 + display: block;
  350 + margin-top: 12px;
  351 +}
  352 +
  353 +.webhook-event-option {
  354 + margin-bottom: 8px;
  355 +}
  356 +
  357 +.event-code {
  358 + margin-left: 8px;
  359 +}
  360 +
  361 +.event-desc {
  362 + color: #999;
  363 + font-size: 12px;
  364 + margin-left: 24px;
  365 + margin-top: 2px;
  366 +}
  367 +
  368 +.log-pagination {
  369 + display: flex;
  370 + justify-content: flex-end;
  371 +}
  372 +
  373 +:global(.webhook-log-detail) {
  374 + max-height: 420px;
  375 + overflow: auto;
  376 + white-space: pre-wrap;
  377 + word-break: break-all;
  378 + background: #f6f8fa;
  379 + border-radius: 6px;
  380 + padding: 12px;
  381 +}
  382 +</style>