Commit 8e93aa70c1e5120a398d3d1838b780ee1cdc035b
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 | 19 | <template v-if="column.key === 'cityId'"> |
| 20 | 20 | {{ getCityName(record.cityId) }} |
| 21 | 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 | 33 | <template v-if="column.key === 'action'"> |
| 23 | 34 | <a-space> |
| 24 | 35 | <a @click="handleResetSecret(record)">重置密钥</a> |
| ... | ... | @@ -65,57 +76,107 @@ |
| 65 | 76 | <a-form-item label="回调地址"> |
| 66 | 77 | <a-input v-model:value="webhookForm.webhookUrl" placeholder="https://your-server.com/webhook" /> |
| 67 | 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 | 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 | 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 | 96 | </a-form> |
| 77 | 97 | </div> |
| 78 | 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 | 101 | <div class="soft-page-stack"> |
| 82 | 102 | <div class="soft-note-card"> |
| 83 | 103 | <strong>推送日志</strong> |
| 84 | 104 | <p>失败记录支持手动重试,便于排查接入方回调地址或签名处理异常。</p> |
| 85 | 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 | 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 | 115 | <template v-if="column.key === 'status'"> |
| 89 | 116 | <a-tag :color="record.status === 1 ? 'green' : 'red'"> |
| 90 | 117 | {{ record.status === 1 ? '成功' : '失败' }} |
| 91 | 118 | </a-tag> |
| 92 | 119 | </template> |
| 120 | + <template v-if="column.key === 'createTime'"> | |
| 121 | + {{ formatTime(record.createTime) }} | |
| 122 | + </template> | |
| 93 | 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 | 129 | </template> |
| 96 | 130 | </template> |
| 97 | 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 | 139 | </div> |
| 99 | 140 | </a-modal> |
| 100 | 141 | </div> |
| 101 | 142 | </template> |
| 102 | 143 | |
| 103 | 144 | <script setup lang="ts"> |
| 104 | -import { ref, reactive, onMounted } from 'vue' | |
| 145 | +import { h, ref, reactive, onMounted } from 'vue' | |
| 105 | 146 | import { message, Modal } from 'ant-design-vue' |
| 106 | 147 | import { openApi } from '@/api' |
| 107 | 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 | 167 | const loading = ref(false) |
| 168 | +const logLoading = ref(false) | |
| 110 | 169 | const saving = ref(false) |
| 111 | 170 | const list = ref<any[]>([]) |
| 112 | 171 | const logs = ref<any[]>([]) |
| 172 | +const logPage = ref(1) | |
| 113 | 173 | const addVisible = ref(false) |
| 114 | 174 | const webhookVisible = ref(false) |
| 115 | 175 | const logsVisible = ref(false) |
| 116 | 176 | const currentAppId = ref(0) |
| 117 | 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 | 180 | const { cityList, loadCities, getCityName } = useRoleCityList() |
| 120 | 181 | |
| 121 | 182 | const columns = [ |
| ... | ... | @@ -125,19 +186,64 @@ const columns = [ |
| 125 | 186 | { title: '租户', key: 'cityId' }, |
| 126 | 187 | { title: '状态', key: 'status' }, |
| 127 | 188 | { title: '回调地址', dataIndex: 'webhookUrl', ellipsis: true }, |
| 189 | + { title: '订阅事件', key: 'webhookEvents' }, | |
| 128 | 190 | { title: '操作', key: 'action' }, |
| 129 | 191 | ] |
| 130 | 192 | |
| 131 | 193 | const logColumns = [ |
| 132 | 194 | { title: 'ID', dataIndex: 'id', width: 80 }, |
| 133 | - { title: '事件', dataIndex: 'event' }, | |
| 195 | + { title: '事件', key: 'event' }, | |
| 134 | 196 | { title: '业务ID', dataIndex: 'bizId' }, |
| 197 | + { title: 'URL', key: 'url', width: 220 }, | |
| 135 | 198 | { title: '响应码', dataIndex: 'responseCode' }, |
| 136 | 199 | { title: '状态', key: 'status' }, |
| 137 | 200 | { title: '重试', dataIndex: 'retryCount' }, |
| 201 | + { title: '时间', key: 'createTime' }, | |
| 138 | 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 | 247 | async function loadList() { |
| 142 | 248 | loading.value = true |
| 143 | 249 | try { |
| ... | ... | @@ -182,14 +288,17 @@ async function toggleStatus(record: any) { |
| 182 | 288 | function openWebhook(record: any) { |
| 183 | 289 | currentAppId.value = record.id |
| 184 | 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 | 294 | webhookVisible.value = true |
| 187 | 295 | } |
| 188 | 296 | |
| 189 | 297 | async function handleWebhookSave() { |
| 298 | + const events = webhookForm.subscribeAll ? ['*'] : webhookForm.webhookEvents | |
| 190 | 299 | saving.value = true |
| 191 | 300 | try { |
| 192 | - await openApi.updateWebhook(currentAppId.value, webhookForm.webhookUrl, webhookForm.webhookEvents) | |
| 301 | + await openApi.updateWebhook(currentAppId.value, webhookForm.webhookUrl, JSON.stringify(events)) | |
| 193 | 302 | message.success('保存成功') |
| 194 | 303 | webhookVisible.value = false |
| 195 | 304 | loadList() |
| ... | ... | @@ -198,17 +307,76 @@ async function handleWebhookSave() { |
| 198 | 307 | |
| 199 | 308 | async function openLogs(record: any) { |
| 200 | 309 | currentAppId.value = record.id |
| 201 | - const res: any = await openApi.webhookLogs(record.id) | |
| 202 | - logs.value = res.data | |
| 203 | 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 | 323 | async function retryLog(logId: number) { |
| 207 | 324 | await openApi.retryWebhook(logId) |
| 208 | 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 | 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> | ... | ... |