OpenAppList.vue 13.5 KB
<template>
  <div>
    <a-card title="开放平台应用管理" :bordered="false" class="list-table-card">
      <div class="list-toolbar">
        <div class="list-toolbar-right">
          <a-button type="primary" @click="openAdd">创建应用</a-button>
        </div>
      </div>
      <a-table :dataSource="list" :columns="columns" :loading="loading" rowKey="id" :pagination="false">
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'status'">
            <a-tag :color="record.status === 1 ? 'green' : 'red'">
              {{ record.status === 1 ? '正常' : '禁用' }}
            </a-tag>
          </template>
          <template v-if="column.key === 'appKey'">
            <a-typography-text copyable>{{ record.appKey }}</a-typography-text>
          </template>
          <template v-if="column.key === 'cityId'">
            {{ getCityName(record.cityId) }}
          </template>
          <template v-if="column.key === 'webhookEvents'">
            <template v-if="parseWebhookEvents(record.webhookEvents).includes('*')">
              <a-tag color="blue">全部事件</a-tag>
            </template>
            <template v-else-if="parseWebhookEvents(record.webhookEvents).length">
              <a-tag v-for="event in parseWebhookEvents(record.webhookEvents)" :key="event">
                {{ getEventLabel(event) }}
              </a-tag>
            </template>
            <span v-else class="muted-text">未订阅</span>
          </template>
          <template v-if="column.key === 'action'">
            <a-space>
              <a @click="handleResetSecret(record)">重置密钥</a>
              <a @click="openWebhook(record)">Webhook</a>
              <a @click="openLogs(record)">推送日志</a>
              <a-popconfirm
                :title="record.status === 1 ? '确认禁用?' : '确认启用?'"
                @confirm="toggleStatus(record)">
                <a :style="record.status === 1 ? 'color:red' : ''">
                  {{ record.status === 1 ? '禁用' : '启用' }}
                </a>
              </a-popconfirm>
            </a-space>
          </template>
        </template>
      </a-table>
    </a-card>

    <a-modal v-model:open="addVisible" title="创建应用" @ok="handleCreate" :confirmLoading="saving">
      <div class="soft-page-stack">
        <div class="soft-note-card">
          <strong>应用创建说明</strong>
          <p>开放应用会绑定到单一租户,后续模拟推单和对外计价都会基于该租户的配置执行。</p>
        </div>
        <a-form :model="addForm" layout="vertical">
          <a-form-item label="应用名称"><a-input v-model:value="addForm.appName" /></a-form-item>
          <a-form-item label="关联租户(必填)">
            <a-select v-model:value="addForm.cityId" placeholder="选择租户" style="width:100%">
              <a-select-option v-for="c in cityList" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
            </a-select>
          </a-form-item>
          <a-form-item label="备注"><a-input v-model:value="addForm.remark" /></a-form-item>
        </a-form>
      </div>
    </a-modal>

    <a-modal v-model:open="webhookVisible" title="Webhook配置" @ok="handleWebhookSave" :confirmLoading="saving">
      <div class="soft-page-stack">
        <div class="soft-note-card">
          <strong>Webhook说明</strong>
          <p>推送日志页可查看回调结果,建议只订阅当前业务实际需要的事件,避免无效重试。</p>
        </div>
        <a-form layout="vertical">
          <a-form-item label="回调地址">
            <a-input v-model:value="webhookForm.webhookUrl" placeholder="https://your-server.com/webhook" />
          </a-form-item>
          <a-form-item label="订阅事件">
            <a-checkbox v-model:checked="webhookForm.subscribeAll">订阅全部事件</a-checkbox>
            <a-checkbox-group
              v-model:value="webhookForm.webhookEvents"
              :disabled="webhookForm.subscribeAll"
              class="webhook-event-group"
            >
              <div v-for="item in webhookEventOptions" :key="item.value" class="webhook-event-option">
                <a-checkbox :value="item.value">
                  <strong>{{ item.label }}</strong>
                  <a-tag class="event-code">{{ item.value }}</a-tag>
                  <div class="event-desc">{{ item.description }}</div>
                </a-checkbox>
              </div>
            </a-checkbox-group>
          </a-form-item>
          <a-alert message="未选择事件时不会主动推送;订单级 callbackUrl 仍会复用这里的订阅事件配置。" type="info" show-icon />
        </a-form>
      </div>
    </a-modal>

    <a-modal v-model:open="logsVisible" title="Webhook推送日志" :footer="null" width="1000px">
      <div class="soft-page-stack">
        <div class="soft-note-card">
          <strong>推送日志</strong>
          <p>失败记录支持手动重试,便于排查接入方回调地址或签名处理异常。</p>
        </div>
        <a-table :dataSource="logs" :columns="logColumns" :loading="logLoading" rowKey="id" size="small" :pagination="false">
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'event'">
              {{ getEventLabel(record.event) }}
              <div class="muted-text">{{ record.event }}</div>
            </template>
            <template v-if="column.key === 'url'">
              <a-typography-text :ellipsis="{ tooltip: record.url }">{{ record.url }}</a-typography-text>
            </template>
            <template v-if="column.key === 'status'">
              <a-tag :color="record.status === 1 ? 'green' : 'red'">
                {{ record.status === 1 ? '成功' : '失败' }}
              </a-tag>
            </template>
            <template v-if="column.key === 'createTime'">
              {{ formatTime(record.createTime) }}
            </template>
            <template v-if="column.key === 'action'">
              <a-space>
                <a @click="showLogDetail(record, 'payload')">Payload</a>
                <a @click="showLogDetail(record, 'response')">响应</a>
                <a v-if="record.status === 0" @click="retryLog(record.id)">重试</a>
              </a-space>
            </template>
          </template>
        </a-table>
        <div class="log-pagination">
          <a-space>
            <a-button :disabled="logPage <= 1 || logLoading" @click="loadLogs(logPage - 1)">上一页</a-button>
            <span>第 {{ logPage }} 页</span>
            <a-button :disabled="logs.length < 20 || logLoading" @click="loadLogs(logPage + 1)">下一页</a-button>
          </a-space>
        </div>
      </div>
    </a-modal>
  </div>
</template>

<script setup lang="ts">
import { h, ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { openApi } from '@/api'
import { useRoleCityList } from '@/composables/useRoleCityList'

type WebhookEventOption = {
  label: string
  value: string
  description: string
}

const defaultWebhookEventOptions: WebhookEventOption[] = [
  { label: '订单创建', value: 'order.created', description: '外部系统推单成功,订单进入待接单' },
  { label: '已派单', value: 'order.dispatched', description: '系统自动派单或后台指派骑手' },
  { label: '骑手接单', value: 'order.accepted', description: '骑手主动抢单成功' },
  { label: '骑手到店', value: 'order.arrived_shop', description: '骑手到达取货点' },
  { label: '骑手取件', value: 'order.picking', description: '骑手取件并开始配送' },
  { label: '订单完成', value: 'order.completed', description: '订单配送完成' },
  { label: '订单取消', value: 'order.cancelled', description: '订单未接单前取消' },
  { label: '订单转单', value: 'order.transferred', description: '转单审核通过,订单重新待接单' },
]

const loading = ref(false)
const logLoading = ref(false)
const saving = ref(false)
const list = ref<any[]>([])
const logs = ref<any[]>([])
const logPage = ref(1)
const addVisible = ref(false)
const webhookVisible = ref(false)
const logsVisible = ref(false)
const currentAppId = ref(0)
const addForm = reactive({ appName: '', cityId: undefined as number | undefined, remark: '' })
const webhookForm = reactive({ webhookUrl: '', webhookEvents: [] as string[], subscribeAll: false })
const webhookEventOptions = ref<WebhookEventOption[]>(defaultWebhookEventOptions)
const { cityList, loadCities, getCityName } = useRoleCityList()

const columns = [
  { title: 'ID', dataIndex: 'id', width: 80 },
  { title: '应用名称', dataIndex: 'appName' },
  { title: 'AppKey', key: 'appKey' },
  { title: '租户', key: 'cityId' },
  { title: '状态', key: 'status' },
  { title: '回调地址', dataIndex: 'webhookUrl', ellipsis: true },
  { title: '订阅事件', key: 'webhookEvents' },
  { title: '操作', key: 'action' },
]

const logColumns = [
  { title: 'ID', dataIndex: 'id', width: 80 },
  { title: '事件', key: 'event' },
  { title: '业务ID', dataIndex: 'bizId' },
  { title: 'URL', key: 'url', width: 220 },
  { title: '响应码', dataIndex: 'responseCode' },
  { title: '状态', key: 'status' },
  { title: '重试', dataIndex: 'retryCount' },
  { title: '时间', key: 'createTime' },
  { title: '操作', key: 'action' },
]

function parseWebhookEvents(raw?: string): string[] {
  if (!raw) return []
  try {
    const events = JSON.parse(raw)
    return Array.isArray(events) ? events.filter(item => typeof item === 'string') : []
  } catch {
    return []
  }
}

function getEventLabel(event: string) {
  if (event === '*') return '全部事件'
  const option = webhookEventOptions.value.find(item => item.value === event)
  return option ? option.label : event
}

function formatTime(epochSecond?: number) {
  if (!epochSecond) return '-'
  return new Date(epochSecond * 1000).toLocaleString()
}

function formatJsonText(value: unknown) {
  if (!value) return ''
  if (typeof value !== 'string') return String(value)
  try {
    return JSON.stringify(JSON.parse(value), null, 2)
  } catch {
    return value
  }
}

async function loadWebhookEvents() {
  try {
    const res: any = await openApi.webhookEvents()
    if (Array.isArray(res.data) && res.data.length) {
      webhookEventOptions.value = res.data
    }
  } catch {
    webhookEventOptions.value = defaultWebhookEventOptions
  }
}

async function loadList() {
  loading.value = true
  try {
    const res: any = await openApi.list()
    list.value = res.data
  } finally { loading.value = false }
}

function openAdd() {
  Object.assign(addForm, { appName: '', cityId: undefined, remark: '' })
  addVisible.value = true
}

async function handleCreate() {
  if (!addForm.cityId) { message.error('请选择租户'); return }
  saving.value = true
  try {
    await openApi.create(addForm)
    message.success('创建成功')
    addVisible.value = false
    loadList()
  } finally { saving.value = false }
}

async function handleResetSecret(record: any) {
  Modal.confirm({
    title: '确认重置 AppSecret?',
    content: '重置后旧密钥立即失效,请及时更新接入方配置',
    onOk: async () => {
      const res: any = await openApi.resetSecret(record.id)
      Modal.info({ title: '新 AppSecret', content: res.data })
    }
  })
}

async function toggleStatus(record: any) {
  await openApi.setStatus(record.id, record.status === 1 ? 0 : 1)
  message.success('操作成功')
  loadList()
}

function openWebhook(record: any) {
  currentAppId.value = record.id
  webhookForm.webhookUrl = record.webhookUrl || ''
  const events = parseWebhookEvents(record.webhookEvents)
  webhookForm.subscribeAll = events.includes('*')
  webhookForm.webhookEvents = events.filter(event => event !== '*')
  webhookVisible.value = true
}

async function handleWebhookSave() {
  const events = webhookForm.subscribeAll ? ['*'] : webhookForm.webhookEvents
  saving.value = true
  try {
    await openApi.updateWebhook(currentAppId.value, webhookForm.webhookUrl, JSON.stringify(events))
    message.success('保存成功')
    webhookVisible.value = false
    loadList()
  } finally { saving.value = false }
}

async function openLogs(record: any) {
  currentAppId.value = record.id
  logsVisible.value = true
  await loadLogs(1)
}

async function loadLogs(page = 1) {
  logLoading.value = true
  try {
    const res: any = await openApi.webhookLogs(currentAppId.value, page)
    logs.value = Array.isArray(res.data) ? res.data : []
    logPage.value = page
  } finally { logLoading.value = false }
}

async function retryLog(logId: number) {
  await openApi.retryWebhook(logId)
  message.success('重试已触发')
  await loadLogs(logPage.value)
}

function showLogDetail(record: any, type: 'payload' | 'response') {
  const title = type === 'payload' ? 'Webhook Payload' : 'Webhook 响应内容'
  const content = type === 'payload' ? formatJsonText(record.payload) : (record.responseBody || '')
  Modal.info({
    title,
    width: 720,
    content: h('pre', { class: 'webhook-log-detail' }, content || '-'),
  })
}

onMounted(() => { loadWebhookEvents(); loadList(); loadCities() })
</script>

<style scoped>
.muted-text {
  color: #999;
  font-size: 12px;
}

.webhook-event-group {
  display: block;
  margin-top: 12px;
}

.webhook-event-option {
  margin-bottom: 8px;
}

.event-code {
  margin-left: 8px;
}

.event-desc {
  color: #999;
  font-size: 12px;
  margin-left: 24px;
  margin-top: 2px;
}

.log-pagination {
  display: flex;
  justify-content: flex-end;
}

:global(.webhook-log-detail) {
  max-height: 420px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-all;
  background: #f6f8fa;
  border-radius: 6px;
  padding: 12px;
}
</style>