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,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> |