first
This commit is contained in:
512
admin-web/src/pages/customers/index.vue
Normal file
512
admin-web/src/pages/customers/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user