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