This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

View File

@@ -0,0 +1,512 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
adminApi,
type EnterpriseCustomer,
type EnterpriseCustomerApp,
type EnterpriseCustomerOrderRef,
type EnterpriseCustomerPayload,
type EnterpriseOrderEvent,
type EnterpriseWebhookDelivery,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const detailLoading = ref(false);
const submitting = ref(false);
const appSubmitting = ref(false);
const drawerVisible = ref(false);
const customerDialogVisible = ref(false);
const appDialogVisible = ref(false);
const secretDialogVisible = ref(false);
const activeTab = ref("apps");
const keyword = ref("");
const status = ref("");
const appName = ref("默认应用");
const oneTimeSecret = ref("");
const customers = ref<EnterpriseCustomer[]>([]);
const currentCustomer = ref<EnterpriseCustomer | null>(null);
const apps = ref<EnterpriseCustomerApp[]>([]);
const orders = ref<EnterpriseCustomerOrderRef[]>([]);
const events = ref<EnterpriseOrderEvent[]>([]);
const deliveries = ref<EnterpriseWebhookDelivery[]>([]);
const customerForm = reactive<EnterpriseCustomerPayload>({
customer_name: "",
contact_name: "",
contact_mobile: "",
contact_email: "",
webhook_url: "",
webhook_enabled: false,
status: "enabled",
remark: "",
});
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "启用中", value: "enabled" },
{ label: "已停用", value: "disabled" },
];
const cards = computed(() => {
const enabled = customers.value.filter((item) => item.status === "enabled").length;
const appsTotal = customers.value.reduce((sum, item) => sum + (item.app_count || 0), 0);
const ordersTotal = customers.value.reduce((sum, item) => sum + (item.order_count || 0), 0);
const eventsTotal = customers.value.reduce((sum, item) => sum + (item.event_count || 0), 0);
return [
{ title: "客户总数", value: customers.value.length, desc: `${enabled} 个客户启用中` },
{ title: "应用 Key", value: appsTotal, desc: "客户可用开放接口应用" },
{ title: "推送订单", value: ordersTotal, desc: "已绑定的外部订单" },
{ title: "状态事件", value: eventsTotal, desc: "订单状态推送事件" },
];
});
async function fetchCustomers() {
loading.value = true;
try {
const response = await adminApi.getCustomers({
keyword: keyword.value,
status: status.value,
});
customers.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("客户列表加载失败");
} finally {
loading.value = false;
}
}
async function refreshDetail(customerId = currentCustomer.value?.id || 0) {
if (!customerId) {
return;
}
detailLoading.value = true;
try {
const [detailRes, ordersRes, eventsRes, deliveriesRes] = await Promise.all([
adminApi.getCustomerDetail(customerId),
adminApi.getCustomerOrders(customerId),
adminApi.getCustomerEvents(customerId),
adminApi.getCustomerDeliveries({ customer_id: customerId }),
]);
currentCustomer.value = detailRes.data.customer;
apps.value = detailRes.data.apps;
orders.value = ordersRes.data.list;
events.value = eventsRes.data.list;
deliveries.value = deliveriesRes.data.list;
} catch (error) {
console.error(error);
ElMessage.error("客户详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function openDetail(row: EnterpriseCustomer) {
drawerVisible.value = true;
currentCustomer.value = row;
activeTab.value = "apps";
await refreshDetail(row.id);
}
function openCustomerDialog(row?: EnterpriseCustomer) {
if (row) {
customerForm.id = row.id;
customerForm.customer_name = row.customer_name;
customerForm.contact_name = row.contact_name;
customerForm.contact_mobile = row.contact_mobile;
customerForm.contact_email = row.contact_email;
customerForm.webhook_url = row.webhook_url;
customerForm.webhook_enabled = row.webhook_enabled;
customerForm.status = row.status;
customerForm.remark = row.remark;
} else {
customerForm.id = undefined;
customerForm.customer_name = "";
customerForm.contact_name = "";
customerForm.contact_mobile = "";
customerForm.contact_email = "";
customerForm.webhook_url = "";
customerForm.webhook_enabled = false;
customerForm.status = "enabled";
customerForm.remark = "";
}
customerDialogVisible.value = true;
}
async function submitCustomer() {
submitting.value = true;
try {
const response = await adminApi.saveCustomer({ ...customerForm });
ElMessage.success(customerForm.id ? "客户已更新" : "客户已创建");
customerDialogVisible.value = false;
await fetchCustomers();
if (drawerVisible.value && currentCustomer.value?.id === response.data.id) {
await refreshDetail(response.data.id);
}
} catch (error) {
console.error(error);
ElMessage.error("客户保存失败");
} finally {
submitting.value = false;
}
}
function openAppDialog() {
appName.value = "默认应用";
appDialogVisible.value = true;
}
async function submitApp() {
if (!currentCustomer.value) {
return;
}
appSubmitting.value = true;
try {
const response = await adminApi.createCustomerApp(currentCustomer.value.id, appName.value);
oneTimeSecret.value = response.data.app_secret;
secretDialogVisible.value = true;
appDialogVisible.value = false;
await refreshDetail(currentCustomer.value.id);
await fetchCustomers();
} catch (error) {
console.error(error);
ElMessage.error("应用 Key 创建失败");
} finally {
appSubmitting.value = false;
}
}
async function toggleApp(row: EnterpriseCustomerApp) {
const nextStatus = row.status === "enabled" ? "disabled" : "enabled";
try {
await adminApi.updateCustomerAppStatus(row.id, nextStatus);
ElMessage.success(nextStatus === "enabled" ? "应用已启用" : "应用已停用");
await refreshDetail();
} catch (error) {
console.error(error);
ElMessage.error("应用状态更新失败");
}
}
async function resetSecret(row: EnterpriseCustomerApp) {
try {
await ElMessageBox.confirm("重置后旧 Secret 将立即失效,新 Secret 只展示一次。确定继续吗?", "重置 Secret", {
type: "warning",
});
const response = await adminApi.resetCustomerAppSecret(row.id);
oneTimeSecret.value = response.data.app_secret;
secretDialogVisible.value = true;
await refreshDetail();
} catch (error) {
if (error !== "cancel") {
console.error(error);
ElMessage.error("Secret 重置失败");
}
}
}
async function resendEvent(row: EnterpriseOrderEvent) {
try {
const response = await adminApi.resendCustomerEvent(row.id);
ElMessage.success(response.data.sent ? "事件已补发成功" : "补发未成功,请查看推送记录");
await refreshDetail();
activeTab.value = "deliveries";
} catch (error) {
console.error(error);
ElMessage.error("事件补发失败");
}
}
function showEventDeliveries(row: EnterpriseOrderEvent) {
activeTab.value = "deliveries";
deliveries.value = deliveries.value.filter((item) => item.event_id === row.id);
adminApi.getCustomerDeliveries({ event_id: row.id })
.then((response) => {
deliveries.value = response.data.list;
})
.catch((error) => {
console.error(error);
ElMessage.error("推送记录加载失败");
});
}
onMounted(fetchCustomers);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索客户名称 / 客户ID / 联系人" clearable style="width: 320px" />
<el-select v-model="status" placeholder="客户状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchCustomers">查询</el-button>
</div>
<el-button type="primary" @click="openCustomerDialog()">新增客户</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="customers" stripe>
<el-table-column prop="customer_name" label="客户名称" min-width="180" />
<el-table-column prop="customer_code" label="客户ID" min-width="190" />
<el-table-column label="联系人" min-width="180">
<template #default="{ row }">
<div>{{ row.contact_name || "-" }}</div>
<div style="color: var(--admin-text-subtle); font-size: 12px">{{ row.contact_mobile || row.contact_email || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="Webhook" min-width="240">
<template #default="{ row }">
<el-tag :type="row.webhook_enabled ? 'success' : 'info'" round>{{ row.webhook_enabled ? "已启用" : "未启用" }}</el-tag>
<span class="inline-url">{{ row.webhook_url || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="app_count" label="Key" min-width="80" />
<el-table-column prop="order_count" label="订单" min-width="80" />
<el-table-column prop="event_count" label="事件" min-width="80" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="warning" @click="openCustomerDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="78%" title="客户详情">
<div v-loading="detailLoading" v-if="currentCustomer">
<div class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">客户资料</div>
<div class="detail-card__desc">
<div class="detail-label">客户名称 / ID</div>
<div class="detail-value">{{ currentCustomer.customer_name }} / {{ currentCustomer.customer_code }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">联系人</div>
<div class="detail-value">{{ currentCustomer.contact_name || "-" }} / {{ currentCustomer.contact_mobile || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">月结与虚拟用户</div>
<div class="detail-value">{{ currentCustomer.settlement_type_text }} / User #{{ currentCustomer.user_id || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">Webhook</div>
<div class="detail-card__desc">
<div class="detail-label">状态</div>
<div class="detail-value">{{ currentCustomer.webhook_enabled ? "已启用" : "未启用" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">URL</div>
<div class="detail-value detail-url">{{ currentCustomer.webhook_url || "-" }}</div>
</div>
<div class="detail-card__desc">
<el-button size="small" @click="openCustomerDialog(currentCustomer)">编辑配置</el-button>
</div>
</div>
</div>
<el-card class="panel-card" shadow="never" style="margin-top: 18px">
<el-tabs v-model="activeTab">
<el-tab-pane label="应用 Key" name="apps">
<div class="filters-row" style="justify-content: flex-end; margin-bottom: 12px">
<el-button type="primary" @click="openAppDialog">创建应用 Key</el-button>
</div>
<el-table :data="apps" stripe>
<el-table-column prop="app_name" label="应用名称" min-width="150" />
<el-table-column prop="app_key" label="App Key" min-width="240" />
<el-table-column prop="secret_last4" label="Secret 后四位" min-width="120" />
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="last_used_at" label="最近使用" min-width="170" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="190">
<template #default="{ row }">
<el-button link type="primary" @click="toggleApp(row)">{{ row.status === "enabled" ? "停用" : "启用" }}</el-button>
<el-button link type="warning" @click="resetSecret(row)">重置 Secret</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="客户订单" name="orders">
<el-table :data="orders" stripe>
<el-table-column prop="external_order_no" label="外部订单号" min-width="180" />
<el-table-column prop="order_no" label="我方订单号" min-width="180" />
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="190" />
<el-table-column prop="product_name" label="商品" min-width="160" />
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="row.display_status || row.order_status" />
</template>
</el-table-column>
<el-table-column prop="pay_amount" label="金额" min-width="90" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
</el-table>
</el-tab-pane>
<el-tab-pane label="状态事件" name="events">
<el-table :data="events" stripe>
<el-table-column prop="event_text" label="事件" min-width="140" />
<el-table-column prop="event_code" label="事件编码" min-width="170" />
<el-table-column prop="external_order_no" label="外部订单号" min-width="170" />
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="occurred_at" label="发生时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="showEventDeliveries(row)">推送记录</el-button>
<el-button link type="warning" @click="resendEvent(row)">补发</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="Webhook 记录" name="deliveries">
<div class="filters-row" style="justify-content: flex-end; margin-bottom: 12px">
<el-button @click="refreshDetail()">查看全部记录</el-button>
</div>
<el-table :data="deliveries" stripe>
<el-table-column prop="event_id" label="事件ID" min-width="90" />
<el-table-column label="推送状态" min-width="120">
<template #default="{ row }">
<OrderStatusTag :status="row.delivery_status_text" />
</template>
</el-table-column>
<el-table-column prop="attempt_no" label="次数" min-width="80" />
<el-table-column prop="http_status" label="HTTP" min-width="90" />
<el-table-column prop="webhook_url" label="Webhook URL" min-width="260" />
<el-table-column prop="error_message" label="错误" min-width="220" />
<el-table-column prop="sent_at" label="发送时间" min-width="170" />
<el-table-column label="人工" min-width="80">
<template #default="{ row }">{{ row.is_manual ? "是" : "否" }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</el-drawer>
<el-dialog v-model="customerDialogVisible" :title="customerForm.id ? '编辑客户' : '新增客户'" width="680px">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="客户名称">
<el-input v-model="customerForm.customer_name" placeholder="请输入客户名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户状态">
<el-radio-group v-model="customerForm.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系人">
<el-input v-model="customerForm.contact_name" placeholder="联系人姓名" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话">
<el-input v-model="customerForm.contact_mobile" placeholder="联系人手机号" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系邮箱">
<el-input v-model="customerForm.contact_email" placeholder="联系人邮箱" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Webhook URL">
<el-input v-model="customerForm.webhook_url" placeholder="https://customer.example.com/webhook" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Webhook 开关">
<el-switch v-model="customerForm.webhook_enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="customerForm.remark" type="textarea" :rows="3" placeholder="客户协作备注" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="customerDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitCustomer">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="appDialogVisible" title="创建应用 Key" width="460px">
<el-form label-position="top">
<el-form-item label="应用名称">
<el-input v-model="appName" placeholder="例如 生产环境 / 测试环境" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="appDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="appSubmitting" @click="submitApp">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px">
<el-alert type="warning" show-icon :closable="false" title="Secret 只展示一次,关闭后无法再次查看。" />
<el-input v-model="oneTimeSecret" readonly style="margin-top: 16px" />
<template #footer>
<el-button type="primary" @click="secretDialogVisible = false">已保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.inline-url {
display: inline-block;
max-width: 170px;
margin-left: 8px;
overflow: hidden;
color: var(--admin-text-subtle);
font-size: 12px;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.detail-url {
overflow-wrap: anywhere;
}
</style>