feat: 添加ERP服务和系统服务,完善员工门户功能

## 新增服务模块

### 1. ERP服务 (hzhub-erp)
- 新增独立的ERP数据适配服务
- 支持SQL Server 2008 R2数据源
- 提供动态API配置管理系统
- 包含客户管理、销售数据等业务接口

### 2. 系统服务 (hzhub-system)
- 新增独立的系统管理服务
- 用户、角色、权限、部门、菜单管理
- 租户管理、操作日志、在线用户监控
- 工作流引擎(warm-flow)集成
- 企业微信审批同步功能

### 3. API网关 (hzhub-gateway)
- 新增Spring Cloud Gateway网关服务
- JWT认证、路由分发、限流熔断
- XSS防护、请求日志记录
- 统一入口端口8080

## 后台管理功能增强

### ERP动态API管理
- 新增动态API配置管理界面
- API测试、文档预览、统计监控
- 错误日志查看、缓存管理
- 从数据库表自动导入API配置

### 系统管理增强
- 企业微信配置管理
- 企业微信审批同步配置
- 部门和用户管理优化

## 员工门户功能完善

### 业务页面
- 审批中心:工作流审批、待办任务
- CRM管理:客户关系管理
- 经销商管理:经销商数据展示
- 供应链管理:采购、库存、销售
- BI报表:数据可视化分析
- ERP数据探索:SQL Server数据查询

### 个人中心
- 基本设置:个人信息管理
- 安全设置:密码修改、登录日志
- 锁屏功能:自动锁屏、手动锁屏

### 其他功能
- 标签页管理:多标签页导航
- 页面缓存:keepAlive缓存机制
- 会话超时:自动检测并提示

## 经销商门户

### 页面路由
- 新增经销商管理页面路由
- AI聊天界面完善

## 文档更新

- ERP API数据库初始化指南
- ERP API前端完整实现文档
- ERP API测试和验证指南
- Gateway路由迁移计划
- 项目配置文档更新

## 部署脚本

- 统一启动/停止/重启脚本
- Docker Compose配置优化
- Nginx配置文件更新

## 技术栈

- 后端: Spring Boot 3.5.8, Java 17
- 前端: Vue 3, TypeScript, Element Plus, Vben Admin
- 工作流: warm-flow 1.8.2
- 网关: Spring Cloud Gateway
- 数据库: MySQL 8.0, SQL Server 2008 R2
- 缓存: Redis 7
- 向量库: Weaviate 1.25.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-08 08:00:19 +00:00
parent e6fc123b1f
commit c2513849b4
1564 changed files with 52903 additions and 641 deletions

View File

@@ -0,0 +1,220 @@
/**
* ERP API 配置管理接口定义
*/
import type { ID, IDS, PageQuery, PageResult } from '#/api/common';
import { requestClient } from '#/api/request';
enum Api {
root = '/erp/api/config',
test = '/erp/api/config/test',
preview = '/erp/api/config/preview',
import = '/erp/api/config/importFromTable',
stats = '/erp/api/config/stats',
errorLog = '/erp/api/config/errorLog',
cache = '/erp/api/config/cache',
}
/**
* 分页查询API配置列表
*/
export function apiConfigList(params?: PageQuery) {
return requestClient.get<PageResult<ErpApiConfigVO>>(Api.root + '/list', { params });
}
/**
* 获取API配置详情
*/
export function apiConfigInfo(apiId: ID) {
return requestClient.get<ErpApiConfigInfoResponse>(`${Api.root}/${apiId}`);
}
/**
* 新增API配置
*/
export function apiConfigAdd(data: Partial<ErpApiConfig>) {
return requestClient.postWithMsg<void>(Api.root, data);
}
/**
* 修改API配置
*/
export function apiConfigEdit(data: Partial<ErpApiConfig>) {
return requestClient.putWithMsg<void>(Api.root, data);
}
/**
* 删除API配置
*/
export function apiConfigRemove(apiIds: IDS) {
return requestClient.deleteWithMsg<void>(`${Api.root}/${apiIds}`);
}
/**
* 更新API状态
*/
export function apiConfigChangeStatus(data: Partial<ErpApiConfig>) {
return requestClient.putWithMsg<void>(`${Api.root}/changeStatus`, data);
}
/**
* 从表导入
*/
export function apiConfigImportFromTable(data: ImportTableRequest) {
return requestClient.postWithMsg<void>(Api.import, data);
}
/**
* 同步表结构
*/
export function apiConfigSyncTable(apiId: ID) {
return requestClient.get<void>(`${Api.root}/syncTable/${apiId}`);
}
/**
* API测试
*/
export function apiConfigTest(apiId: ID, params: Record<string, any>) {
return requestClient.post<ApiTestResultVO>(`${Api.test}/${apiId}`, params);
}
/**
* API文档预览
*/
export function apiConfigPreview(apiId: ID) {
return requestClient.get<Record<string, string>>(`${Api.preview}/${apiId}`);
}
/**
* 查询调用统计
*/
export function apiConfigStats(apiId: ID, startTime?: string, endTime?: string) {
return requestClient.get<ApiStatsResponse>(`${Api.stats}/${apiId}`, {
params: { startTime, endTime },
});
}
/**
* 查询错误日志
*/
export function apiConfigErrorLog(apiId: ID, limit?: number) {
return requestClient.get<ApiErrorLogItem[]>(`${Api.errorLog}/${apiId}`, {
params: { limit: limit || 10 },
});
}
/**
* 清除缓存
*/
export function apiConfigClearCache(apiId: ID) {
return requestClient.deleteWithMsg<void>(`${Api.cache}/${apiId}`);
}
/**
* API配置VO
*/
export interface ErpApiConfigVO {
apiId: number;
apiName: string;
apiPath: string;
apiMethod: string;
apiDesc: string;
apiVersion: string;
dataSource: string;
sqlTemplate: string;
resultType: string;
supportPagination: number;
pageParamName: string;
sizeParamName: string;
requireAuth: number;
permissionCode: string;
enableCache: number;
cacheKeyTemplate: string;
cacheTtl: number;
sourceTable: string;
sourceTableComment: string;
status: number;
createTime: string;
updateTime: string;
createBy: string;
updateBy: string;
remark: string;
}
/**
* API配置实体
*/
export interface ErpApiConfig extends ErpApiConfigVO {
params?: ErpApiParam[];
}
/**
* API参数配置
*/
export interface ErpApiParam {
paramId: number;
apiId: number;
paramName: string;
paramDesc: string;
paramType: string;
paramPosition: string;
isRequired: number;
defaultValue: string;
sqlParamName: string;
sort: number;
}
/**
* API配置详情响应
*/
export interface ErpApiConfigInfoResponse {
info: ErpApiConfig;
params: ErpApiParam[];
}
/**
* API测试结果
*/
export interface ApiTestResultVO {
apiPath: string;
testMethod: string;
requestParams: Record<string, any>;
success: boolean;
data: any;
executionTime: number;
executedSql: string;
errorMessage: string;
errorStack: string;
}
/**
* API统计响应
*/
export interface ApiStatsResponse {
totalCalls: number;
avgResponseTime: number;
errorRate: number;
}
/**
* API错误日志项
*/
export interface ApiErrorLogItem {
statsId: number;
apiId: number;
callTime: string;
callParams: string;
responseTime: number;
callStatus: string;
errorMessage: string;
errorStack: string;
clientIp: string;
userId: string;
}
/**
* 从表导入请求
*/
export interface ImportTableRequest {
tableNames: string[];
dataSource?: string;
}

View File

@@ -0,0 +1,97 @@
/**
* ERP动态API配置类型定义
*/
export interface ErpApiConfig {
apiId: number;
apiName: string;
apiPath: string;
apiMethod: string;
apiDesc?: string;
apiVersion: string;
// 数据源配置
dataSource: string;
// SQL配置
sqlTemplate: string;
resultType: string;
// 分页配置
supportPagination: number;
pageParamName?: string;
sizeParamName?: string;
// 权限配置
requireAuth: number;
permissionCode?: string;
// 缓存配置
enableCache: number;
cacheKeyTemplate?: string;
cacheTtl?: number;
// 来源表信息
sourceTable?: string;
sourceTableComment?: string;
// 状态
status: number;
createTime?: string;
updateTime?: string;
createBy?: string;
updateBy?: string;
remark?: string;
}
export interface ErpApiParam {
paramId: number;
apiId: number;
// 参数基本信息
paramName: string;
paramDesc?: string;
paramType: string;
// 参数位置
paramPosition: string;
// 参数验证
isRequired: number;
defaultValue?: string;
// SQL映射
sqlParamName?: string;
// 排序
sort?: number;
createTime?: string;
updateTime?: string;
}
export interface ApiTestResult {
apiPath: string;
testMethod: string;
requestParams?: Record<string, any>;
success: boolean;
data?: any;
executionTime?: number;
executedSql?: string;
errorMessage?: string;
errorStack?: string;
}
export interface ApiStats {
totalCalls: number;
successCalls: number;
errorCalls: number;
avgResponseTime: number;
maxResponseTime: number;
minResponseTime: number;
errorRate: number;
}
export interface ApiConfigDetail {
info: ErpApiConfig;
params: ErpApiParam[];
}

View File

@@ -60,3 +60,10 @@ export function deptUpdate(data: Partial<Dept>) {
export function deptRemove(deptId: ID) {
return requestClient.deleteWithMsg<void>(`${Api.root}/${deptId}`);
}
/**
* 从企业微信同步部门
*/
export function deptSyncFromWecom() {
return requestClient.postWithMsg<string>(`${Api.root}/syncFromWecom`);
}

View File

@@ -169,3 +169,10 @@ export function getDeptTree() {
export function listUserByDeptId(deptId: ID) {
return requestClient.get<User[]>(`${Api.listDeptUsers}/${deptId}`);
}
/**
* 从企业微信同步用户
*/
export function userSyncFromWecom() {
return requestClient.postWithMsg<string>(`${Api.root}/syncFromWecom`);
}

View File

@@ -0,0 +1,68 @@
import { requestClient } from '#/api/request';
enum Api {
syncFull = '/wecom/approval/sync/full',
syncCurrent = '/wecom/approval/sync/current',
syncLogs = '/wecom/approval/sync/logs',
}
/**
* 全量同步审批数据(管理员)
* @param daysBack 同步近 N 天数据默认30天
*/
export function wecomApprovalSyncFull(daysBack = 30) {
return requestClient.post<number>(`${Api.syncFull}`, null, {
params: { daysBack },
// 设置较长的超时时间60秒因为即使异步启动同步也需要时间
timeout: 60000,
});
}
/**
* 同步当前用户审批数据
*/
export function wecomApprovalSyncCurrent() {
return requestClient.post<string>(Api.syncCurrent, {});
}
/**
* 同步审批模板(管理员)
*/
export function wecomApprovalSyncTemplates() {
return requestClient.post<string>('/wecom/approval/templates/sync', {});
}
/**
* 查询同步日志
*/
export function wecomApprovalSyncLogs(params: { pageNum: number; pageSize: number }) {
return requestClient.get<{ rows: any[]; total: number }>(Api.syncLogs, { params });
}
/**
* 获取定时任务状态
*/
export function getTaskStatus() {
return requestClient.get<{ running: boolean; cron: string }>('/wecom/approval/sync/task/status');
}
/**
* 启动定时任务
*/
export function startTask() {
return requestClient.post<string>('/wecom/approval/sync/task/start', {});
}
/**
* 停止定时任务
*/
export function stopTask() {
return requestClient.post<string>('/wecom/approval/sync/task/stop', {});
}
/**
* 设置同步频率
*/
export function setCron(cron: string) {
return requestClient.post<string>('/wecom/approval/sync/task/cron', null, { params: { cron } });
}

View File

@@ -0,0 +1,29 @@
import { requestClient } from '#/api/request';
enum Api {
root = '/wecom/tenant-config',
test = '/wecom/tenant-config/test',
}
/**
* 获取当前租户的企业微信配置
*/
export function getWecomConfig() {
return requestClient.get(Api.root);
}
/**
* 保存/更新企业微信配置
*/
export function saveWecomConfig(data: any) {
return requestClient.put(Api.root, data);
}
/**
* 测试企业微信连接
*/
export function testWecomConfig(corpid: string, corpsecret: string) {
return requestClient.post(Api.test, null, {
params: { corpid, corpsecret },
});
}

View File

@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, ref, provide } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { message, Skeleton, Tabs, TabPane } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { apiConfigInfo, apiConfigAdd, apiConfigEdit } from '#/api/erp/api';
import type { ErpApiConfig, ErpApiParam } from '#/api/erp/api';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import {
apiMethodOptions,
apiVersionOptions,
resultTypeOptions,
dataSourceOptions,
} from './data';
import SQLTemplate from './edit-tabs/sql-template.vue';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? '编辑API配置' : '新增API配置';
});
const currentTab = ref('basic');
const paramsData = ref<ErpApiParam[]>([]);
const apiData = ref<ErpApiConfig>({} as ErpApiConfig);
// 提供数据给子组件
provide('apiData', apiData);
provide('paramsData', paramsData);
// 基础设置表单Schema
const basicSchema = () => [
{
component: 'Input',
fieldName: 'apiId',
label: 'API ID',
dependencies: {
show: () => false,
triggerFields: ['apiId'],
},
},
{
component: 'Input',
fieldName: 'apiName',
label: 'API名称',
rules: 'required',
componentProps: {
placeholder: '请输入API名称',
},
},
{
component: 'Input',
fieldName: 'apiPath',
label: 'API路径',
rules: 'required',
componentProps: {
placeholder: '例如:/erp/dynamic/v1/customer_list',
},
},
{
component: 'Select',
fieldName: 'apiMethod',
label: 'HTTP方法',
rules: 'required',
componentProps: {
options: apiMethodOptions,
},
},
{
component: 'InputTextArea',
fieldName: 'apiDesc',
label: 'API描述',
componentProps: {
placeholder: '请输入API描述',
rows: 2,
},
},
{
component: 'Select',
fieldName: 'apiVersion',
label: 'API版本',
componentProps: {
options: apiVersionOptions,
},
},
{
component: 'Select',
fieldName: 'dataSource',
label: '数据源',
componentProps: {
options: dataSourceOptions,
},
},
{
component: 'Select',
fieldName: 'resultType',
label: '结果类型',
rules: 'required',
componentProps: {
options: resultTypeOptions,
},
},
{
component: 'Switch',
fieldName: 'supportPagination',
label: '支持分页',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
checkedValue: 1,
unCheckedValue: 0,
},
},
{
component: 'Input',
fieldName: 'pageParamName',
label: '页码参数名',
dependencies: {
show: (values) => values.supportPagination === 1,
triggerFields: ['supportPagination'],
},
componentProps: {
placeholder: '默认pageNum',
},
},
{
component: 'Input',
fieldName: 'sizeParamName',
label: '页大小参数名',
dependencies: {
show: (values) => values.supportPagination === 1,
triggerFields: ['supportPagination'],
},
componentProps: {
placeholder: '默认pageSize',
},
},
{
component: 'Switch',
fieldName: 'requireAuth',
label: '需要认证',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
checkedValue: 1,
unCheckedValue: 0,
},
},
{
component: 'Input',
fieldName: 'permissionCode',
label: '权限标识',
dependencies: {
show: (values) => values.requireAuth === 1,
triggerFields: ['requireAuth'],
},
componentProps: {
placeholder: '例如erp:customer:list',
},
},
{
component: 'Switch',
fieldName: 'enableCache',
label: '启用缓存',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
checkedValue: 1,
unCheckedValue: 0,
},
},
{
component: 'Input',
fieldName: 'cacheKeyTemplate',
label: '缓存键模板',
dependencies: {
show: (values) => values.enableCache === 1,
triggerFields: ['enableCache'],
},
componentProps: {
placeholder: '支持参数占位符',
},
},
{
component: 'InputNumber',
fieldName: 'cacheTtl',
label: '缓存过期时间',
dependencies: {
show: (values) => values.enableCache === 1,
triggerFields: ['enableCache'],
},
componentProps: {
placeholder: '秒',
min: 1,
},
},
{
component: 'Input',
fieldName: 'sourceTable',
label: '来源表名',
componentProps: {
disabled: true,
placeholder: '如果是从表导入生成的,会显示来源表名',
},
},
{
component: 'InputTextArea',
fieldName: 'remark',
label: '备注',
componentProps: {
placeholder: '请输入备注',
rows: 2,
},
},
];
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
labelWidth: 120,
},
schema: basicSchema(),
showDefaultActions: false,
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const loading = ref(false);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
loading.value = true;
const { apiId } = drawerApi.getData() as { apiId?: number | string };
isUpdate.value = !!apiId;
if (isUpdate.value && apiId) {
const response = await apiConfigInfo(Number(apiId));
await formApi.setValues(response.info);
apiData.value = response.info;
paramsData.value = response.params || [];
} else {
// 设置默认值
const defaultData = {
apiMethod: 'GET',
apiVersion: 'v1',
dataSource: 'erp',
resultType: 'LIST',
supportPagination: 0,
pageParamName: 'pageNum',
sizeParamName: 'pageSize',
requireAuth: 0,
enableCache: 0,
cacheTtl: 300,
status: 1,
sqlTemplate: '',
};
await formApi.setValues(defaultData);
apiData.value = defaultData as ErpApiConfig;
paramsData.value = [];
}
await markInitialized();
drawerApi.drawerLoading(false);
loading.value = false;
},
});
async function handleConfirm() {
try {
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
currentTab.value = 'basic';
return;
}
const formValues = cloneDeep(await formApi.getValues());
// 验证必填字段
if (!formValues.apiName || !formValues.apiPath) {
message.error('请填写必填字段API名称、API路径');
currentTab.value = 'basic';
return;
}
// 验证SQL模板
if (!apiData.value.sqlTemplate) {
message.error('请填写SQL模板');
currentTab.value = 'sql';
return;
}
// 合并数据formApi数据 + apiData中的sqlTemplate
const submitData = {
...formValues,
sqlTemplate: apiData.value.sqlTemplate,
params: paramsData.value,
};
if (isUpdate.value) {
await apiConfigEdit(submitData);
message.success('修改成功');
} else {
await apiConfigAdd(submitData);
message.success('新增成功');
}
resetInitialized();
emit('reload');
drawerApi.close();
} catch (error: any) {
message.error('保存失败: ' + (error.message || '未知错误'));
} finally {
drawerApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
paramsData.value = [];
resetInitialized();
}
</script>
<template>
<BasicDrawer :title="title" class="w-[800px]">
<Skeleton v-if="loading" active />
<Tabs v-show="!loading" v-model:activeKey="currentTab">
<TabPane key="basic" tab="基础设置">
<BasicForm />
</TabPane>
<TabPane key="params" tab="参数配置">
<div class="p-4">
<div class="mb-2">
<a-button type="primary" @click="paramsData.push({
paramId: Date.now(),
apiId: 0,
paramName: '',
paramType: 'String',
paramPosition: 'QUERY',
isRequired: 0,
defaultValue: '',
paramDesc: '',
sort: paramsData.length,
})">
新增参数
</a-button>
</div>
<a-table :data-source="paramsData" :columns="[
{ title: '参数名称', dataIndex: 'paramName', width: 150 },
{ title: '参数类型', dataIndex: 'paramType', width: 120 },
{ title: '参数位置', dataIndex: 'paramPosition', width: 100 },
{ title: '必填', dataIndex: 'isRequired', width: 80 },
{ title: '默认值', dataIndex: 'defaultValue', width: 120 },
{ title: '参数描述', dataIndex: 'paramDesc', width: 200 },
{ title: '操作', dataIndex: 'action', width: 100, fixed: 'right' },
]" :pagination="false" size="small">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'paramName'">
<a-input v-model:value="record.paramName" size="small" />
</template>
<template v-if="column.dataIndex === 'paramType'">
<a-select v-model:value="record.paramType" size="small" :options="[
{ label: 'String', value: 'String' },
{ label: 'Integer', value: 'Integer' },
{ label: 'Long', value: 'Long' },
{ label: 'Date', value: 'Date' },
]" />
</template>
<template v-if="column.dataIndex === 'paramPosition'">
<a-select v-model:value="record.paramPosition" size="small" :options="[
{ label: 'QUERY', value: 'QUERY' },
{ label: 'BODY', value: 'BODY' },
]" />
</template>
<template v-if="column.dataIndex === 'isRequired'">
<a-switch v-model:checked="record.isRequired" :checked-value="1" :un-checked-value="0" size="small" />
</template>
<template v-if="column.dataIndex === 'defaultValue'">
<a-input v-model:value="record.defaultValue" size="small" />
</template>
<template v-if="column.dataIndex === 'paramDesc'">
<a-input v-model:value="record.paramDesc" size="small" />
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" danger size="small" @click="paramsData.splice(index, 1)">
删除
</a-button>
</template>
</template>
</a-table>
</div>
</TabPane>
<TabPane key="sql" tab="SQL模板">
<SQLTemplate />
</TabPane>
</Tabs>
</BasicDrawer>
</template>

View File

@@ -0,0 +1,240 @@
/**
* ERP API 配置管理 - 数据定义
*/
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
/**
* 搜索表单 Schema
*/
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'apiName',
label: 'API名称',
componentProps: {
placeholder: '请输入API名称',
allowClear: true,
},
},
{
component: 'Input',
fieldName: 'apiPath',
label: 'API路径',
componentProps: {
placeholder: '请输入API路径',
allowClear: true,
},
},
{
component: 'Select',
fieldName: 'apiMethod',
label: 'HTTP方法',
componentProps: {
placeholder: '请选择HTTP方法',
options: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
],
allowClear: true,
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
placeholder: '请选择状态',
options: [
{ label: '启用', value: '1' },
{ label: '禁用', value: '0' },
],
allowClear: true,
},
},
];
/**
* 表格列定义
*/
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'apiName',
title: 'API名称',
minWidth: 150,
showOverflow: 'tooltip',
},
{
field: 'apiPath',
title: 'API路径',
minWidth: 200,
showOverflow: 'tooltip',
},
{
field: 'apiMethod',
title: 'HTTP方法',
width: 100,
},
{
field: 'apiVersion',
title: '版本',
width: 80,
},
{
field: 'resultType',
title: '结果类型',
width: 100,
},
{
field: 'supportPagination',
title: '分页',
width: 80,
slots: { default: 'pagination' },
},
{
field: 'requireAuth',
title: '认证',
width: 80,
slots: { default: 'auth' },
},
{
field: 'enableCache',
title: '缓存',
width: 80,
slots: { default: 'cache' },
},
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{
field: 'createTime',
title: '创建时间',
width: 150,
formatter: 'formatDateTime',
},
{
field: 'action',
title: '操作',
width: 250,
fixed: 'right',
slots: { default: 'action' },
},
];
/**
* API方法选项
*/
export const apiMethodOptions = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
];
/**
* API版本选项
*/
export const apiVersionOptions = [
{ label: 'v1', value: 'v1' },
{ label: 'v2', value: 'v2' },
];
/**
* 结果类型选项
*/
export const resultTypeOptions = [
{ label: '列表', value: 'LIST' },
{ label: '单条', value: 'SINGLE' },
{ label: '计数', value: 'COUNT' },
];
/**
* 参数类型选项
*/
export const paramTypeOptions = [
{ label: 'String', value: 'String' },
{ label: 'Integer', value: 'Integer' },
{ label: 'Long', value: 'Long' },
{ label: 'Double', value: 'Double' },
{ label: 'Date', value: 'Date' },
{ label: 'DateTime', value: 'DateTime' },
{ label: 'Boolean', value: 'Boolean' },
];
/**
* 参数位置选项
*/
export const paramPositionOptions = [
{ label: 'QUERY', value: 'QUERY' },
{ label: 'BODY', value: 'BODY' },
];
/**
* 数据源选项(待扩展)
*/
export const dataSourceOptions = [
{ label: 'ERP数据源', value: 'erp' },
];
/**
* 参数配置表格列定义
*/
export const paramColumns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'paramName',
title: '参数名称',
minWidth: 150,
editRender: { name: 'input' },
},
{
field: 'paramType',
title: '参数类型',
width: 120,
editRender: {
name: 'select',
options: paramTypeOptions,
},
},
{
field: 'paramPosition',
title: '参数位置',
width: 100,
editRender: {
name: 'select',
options: paramPositionOptions,
},
},
{
field: 'isRequired',
title: '必填',
width: 80,
editRender: { name: 'checkbox' },
},
{
field: 'defaultValue',
title: '默认值',
minWidth: 120,
editRender: { name: 'input' },
},
{
field: 'paramDesc',
title: '参数描述',
minWidth: 200,
editRender: { name: 'input' },
},
{
field: 'sort',
title: '排序',
width: 80,
editRender: { name: 'input', attrs: { type: 'number' } },
},
{
field: 'action',
title: '操作',
width: 100,
slots: { default: 'paramAction' },
},
];

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { Tabs, TabPane, Spin, message, Card } from 'ant-design-vue';
import { ref } from 'vue';
import { apiConfigPreview } from '#/api/erp/api';
const apiId = ref<number>();
const apiName = ref<string>();
const docData = ref<Record<string, string>>({});
const loading = ref(false);
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (!isOpen) return;
const data = modalApi.getData() as { apiId: number; apiName: string };
apiId.value = data.apiId;
apiName.value = data.apiName;
loading.value = true;
try {
const response = await apiConfigPreview(data.apiId);
docData.value = response;
} catch (error) {
message.error('加载文档失败');
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal
:title="`API文档 - ${apiName}`"
class="w-[700px]"
:footer="null"
>
<Spin v-if="loading" />
<Tabs v-else>
<TabPane key="basic" tab="基本信息">
<Card>
<pre class="doc-output">{{ docData.basic || '无基本信息' }}</pre>
</Card>
</TabPane>
<TabPane key="params" tab="参数说明">
<Card>
<pre class="doc-output">{{ docData.params || '无参数' }}</pre>
</Card>
</TabPane>
<TabPane key="sql" tab="SQL模板">
<Card>
<pre class="sql-output">{{ docData.sql || '' }}</pre>
</Card>
</TabPane>
<TabPane key="example" tab="使用示例">
<Card>
<div v-if="docData.example">
<pre class="doc-output">{{ docData.example }}</pre>
</div>
<div v-else>
<p>暂无使用示例</p>
</div>
</Card>
</TabPane>
</Tabs>
</Modal>
</template>
<style scoped>
.doc-output,
.sql-output {
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Card, Tabs, TabPane, Button, Space, Spin, message } from 'ant-design-vue';
import { ref, onMounted, provide, useTemplateRef } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { apiConfigInfo, apiConfigAdd, apiConfigEdit } from '#/api/erp/api';
import type { ErpApiConfig, ErpApiParam } from '#/api/erp/api';
import BasicSetting from './edit-tabs/basic-setting.vue';
import ParamsConfig from './edit-tabs/params-config.vue';
const router = useRouter();
const route = useRoute();
const apiId = route.query.apiId as string;
const isUpdate = ref<boolean>(!!apiId);
const apiData = ref<ErpApiConfig>({
apiId: undefined,
apiName: '',
apiPath: '',
apiMethod: 'GET',
apiDesc: '',
apiVersion: 'v1',
dataSource: 'erp',
sqlTemplate: '',
resultType: 'LIST',
supportPagination: 0,
pageParamName: 'pageNum',
sizeParamName: 'pageSize',
requireAuth: 0,
permissionCode: '',
enableCache: 0,
cacheKeyTemplate: '',
cacheTtl: 300,
status: 1,
});
const paramsData = ref<ErpApiParam[]>([]);
const loading = ref(false);
const currentTab = ref('basic');
// 子组件引用
const basicSettingRef = useTemplateRef('basicSettingRef');
const paramsConfigRef = useTemplateRef('paramsConfigRef');
// 提供数据给子组件
provide('apiData', apiData);
provide('paramsData', paramsData);
// 加载API配置详情
async function loadApiInfo() {
if (!apiId) return;
loading.value = true;
try {
const response = await apiConfigInfo(Number(apiId));
apiData.value = response.info;
paramsData.value = response.params || [];
} catch (error) {
message.error('加载API配置失败');
router.push({ path: '/erp/api' });
} finally {
loading.value = false;
}
}
onMounted(() => {
loadApiInfo();
});
// 保存配置
async function handleSave() {
try {
// 验证表单
const valid = await basicSettingRef.value?.validateForm();
if (!valid) {
message.error('请填写必填字段');
currentTab.value = 'basic';
return;
}
// 获取表单数据
const formValues = await basicSettingRef.value?.getFormValues();
if (!formValues) {
message.error('获取表单数据失败');
return;
}
// 验证必填字段
if (!formValues.apiName || !formValues.apiPath || !formValues.sqlTemplate) {
message.error('请填写必填字段API名称、API路径、SQL模板');
currentTab.value = 'basic';
return;
}
// 获取参数配置数据
const params = paramsConfigRef.value?.getTableData() || [];
// 合并数据
const submitData = {
...formValues,
params,
};
if (isUpdate.value) {
await apiConfigEdit(submitData);
message.success('修改成功');
} else {
await apiConfigAdd(submitData);
message.success('新增成功');
}
router.push({ path: '/erp/api' });
} catch (error: any) {
message.error('保存失败: ' + (error.message || '未知错误'));
}
}
// 返回列表
function handleBack() {
router.push({ path: '/erp/api' });
}
</script>
<template>
<Page :auto-content-height="true">
<Card>
<Spin v-if="loading" />
<Tabs v-else v-model:activeKey="currentTab">
<template #rightExtra>
<Space>
<Button @click="handleBack">返回</Button>
<Button type="primary" @click="handleSave">保存配置</Button>
</Space>
</template>
<TabPane key="basic" tab="基础设置">
<BasicSetting ref="basicSettingRef" />
</TabPane>
<TabPane key="params" tab="参数配置">
<ParamsConfig ref="paramsConfigRef" />
</TabPane>
</Tabs>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
import { inject, ref, watch } from 'vue';
import {
apiMethodOptions,
apiVersionOptions,
resultTypeOptions,
dataSourceOptions,
} from '../data';
import type { ErpApiConfig } from '#/api/erp/api';
const apiData = inject<ref<ErpApiConfig>>('apiData')!;
const schema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'apiId',
label: 'API ID',
dependencies: {
show: () => false,
triggerFields: ['apiId'],
},
},
{
component: 'Input',
fieldName: 'apiName',
label: 'API名称',
rules: 'required',
componentProps: {
placeholder: '请输入API名称',
},
},
{
component: 'Input',
fieldName: 'apiPath',
label: 'API路径',
rules: 'required',
componentProps: {
placeholder: '例如:/erp/dynamic/v1/customer_list',
},
},
{
component: 'Select',
fieldName: 'apiMethod',
label: 'HTTP方法',
rules: 'required',
componentProps: {
options: apiMethodOptions,
},
},
{
component: 'InputTextArea',
fieldName: 'apiDesc',
label: 'API描述',
componentProps: {
placeholder: '请输入API描述',
rows: 2,
},
},
{
component: 'Select',
fieldName: 'apiVersion',
label: 'API版本',
componentProps: {
options: apiVersionOptions,
},
},
{
component: 'Select',
fieldName: 'dataSource',
label: '数据源',
componentProps: {
options: dataSourceOptions,
},
},
{
component: 'InputTextArea',
fieldName: 'sqlTemplate',
label: 'SQL模板',
rules: 'required',
componentProps: {
placeholder: '请输入SQL模板支持参数占位符 #{paramName}',
rows: 6,
},
helpMessage: '使用 #{paramName} 作为参数占位符例如SELECT * FROM table WHERE #{id} IS NOT NULL THEN id = #{id}',
},
{
component: 'Select',
fieldName: 'resultType',
label: '结果类型',
rules: 'required',
componentProps: {
options: resultTypeOptions,
},
},
{
component: 'Switch',
fieldName: 'supportPagination',
label: '支持分页',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
},
{
component: 'Input',
fieldName: 'pageParamName',
label: '页码参数名',
dependencies: {
show: (values) => values.supportPagination === 1,
triggerFields: ['supportPagination'],
},
componentProps: {
placeholder: '默认pageNum',
},
},
{
component: 'Input',
fieldName: 'sizeParamName',
label: '页大小参数名',
dependencies: {
show: (values) => values.supportPagination === 1,
triggerFields: ['supportPagination'],
},
componentProps: {
placeholder: '默认pageSize',
},
},
{
component: 'Switch',
fieldName: 'requireAuth',
label: '需要认证',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
},
{
component: 'Input',
fieldName: 'permissionCode',
label: '权限标识',
dependencies: {
show: (values) => values.requireAuth === 1,
triggerFields: ['requireAuth'],
},
componentProps: {
placeholder: '例如erp:customer:list',
},
},
{
component: 'Switch',
fieldName: 'enableCache',
label: '启用缓存',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
},
{
component: 'Input',
fieldName: 'cacheKeyTemplate',
label: '缓存键模板',
dependencies: {
show: (values) => values.enableCache === 1,
triggerFields: ['enableCache'],
},
componentProps: {
placeholder: '支持参数占位符',
},
},
{
component: 'InputNumber',
fieldName: 'cacheTtl',
label: '缓存过期时间',
dependencies: {
show: (values) => values.enableCache === 1,
triggerFields: ['enableCache'],
},
componentProps: {
placeholder: '秒',
min: 1,
},
},
{
component: 'Input',
fieldName: 'sourceTable',
label: '来源表名',
componentProps: {
disabled: true,
placeholder: '如果是从表导入生成的,会显示来源表名',
},
},
{
component: 'InputTextArea',
fieldName: 'remark',
label: '备注',
componentProps: {
placeholder: '请输入备注',
rows: 2,
},
},
];
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
labelWidth: 120,
},
schema: schema(),
showDefaultActions: false,
});
// 监听数据变化,同步到表单
watch(apiData, (newData) => {
if (newData) {
formApi.setValues(newData);
}
}, { immediate: true, deep: true });
// 获取表单数据(供父组件调用)
function getFormValues() {
return formApi.getValues();
}
// 验证表单(供父组件调用)
async function validateForm() {
const { valid } = await formApi.validate();
return valid;
}
// 暴露方法给父组件
defineExpose({
getFormValues,
validateForm,
});
</script>
<template>
<BasicForm />
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { VxeGridProps } from '#/adapter/vxe-table';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { Button, Space, message } from 'ant-design-vue';
import { inject, ref } from 'vue';
import { paramColumns } from '../data';
import type { ErpApiParam } from '#/api/erp/api';
const paramsData = inject<ref<ErpApiParam[]>>('paramsData')!;
const gridOptions: VxeGridProps<ErpApiParam> = {
columns: paramColumns,
data: paramsData.value,
editConfig: {
trigger: 'click',
mode: 'cell',
showStatus: true,
},
rowConfig: {
keyField: 'paramId',
},
id: 'erp-api-params-config',
};
const [ParamsTable, tableApi] = useVbenVxeGrid({
gridOptions,
});
// 新增参数
function handleAddParam() {
const newParam: ErpApiParam = {
paramId: Date.now(), // 临时ID
apiId: 0,
paramName: '',
paramType: 'String',
paramPosition: 'QUERY',
isRequired: 0,
defaultValue: '',
paramDesc: '',
sort: paramsData.value.length,
};
tableApi.insert(newParam);
}
// 删除选中参数
async function handleDeleteParam() {
const selectRows = tableApi.getSelectRows();
if (selectRows.length === 0) {
message.warning('请选择要删除的参数');
return;
}
await tableApi.remove(selectRows);
message.success('删除成功');
}
// 获取表格数据(供父组件调用)
function getTableData(): ErpApiParam[] {
return tableApi.getTableData().fullData;
}
// 暴露方法给父组件
defineExpose({
getTableData,
});
</script>
<template>
<div class="params-config-container">
<div class="toolbar mb-2">
<Space>
<Button type="primary" @click="handleAddParam">新增参数</Button>
<Button danger @click="handleDeleteParam">删除选中</Button>
</Space>
</div>
<ParamsTable>
<template #paramAction="{ row }">
<Button size="small" type="link" danger @click="tableApi.remove(row)">
删除
</Button>
</template>
</ParamsTable>
</div>
</template>
<style scoped>
.params-config-container {
padding: 16px 0;
}
</style>

View File

@@ -0,0 +1,293 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button, Card, Col, Divider, Input, Row, Space, Spin, message } from 'ant-design-vue';
import { inject } from 'vue';
import type { ErpApiConfig, ErpApiParam } from '#/api/erp/api';
// 接收父组件注入的数据
const apiData = inject<ref<ErpApiConfig>>('apiData')!;
const paramsData = inject<ref<ErpApiParam[]>>('paramsData')!;
// SQL模板内容
const sqlTemplate = ref<string>('');
// 监听数据变化同步SQL模板
watch(apiData, (newData) => {
if (newData && newData.sqlTemplate) {
sqlTemplate.value = newData.sqlTemplate;
}
}, { immediate: true, deep: true });
// 监听SQL模板变化同步回父组件
watch(sqlTemplate, (newSql) => {
if (apiData.value) {
apiData.value.sqlTemplate = newSql;
}
});
// 测试参数值(用于预览)
const testParamValues = ref<Record<string, any>>({});
// 初始化测试参数值
watch(paramsData, (params) => {
const values: Record<string, any> = {};
params.forEach((param) => {
// 如果参数有默认值,使用默认值;否则使用示例值
values[param.paramName] = param.defaultValue || getExampleValue(param.paramType);
});
testParamValues.value = values;
}, { immediate: true, deep: true });
// 根据参数类型生成示例值
function getExampleValue(paramType: string): any {
switch (paramType) {
case 'String': return '示例值';
case 'Integer': return 123;
case 'Long': return 123456789;
case 'Double': return 123.45;
case 'Date': return '2026-04-30';
case 'Boolean': return true;
default: return '示例';
}
}
// 参数注入后的SQL预览
const previewSql = computed(() => {
let sql = sqlTemplate.value;
// 替换 #{paramName} 为参数值
const paramPattern = /#\{(\w+)\}/g;
sql = sql.replace(paramPattern, (match, paramName) => {
const value = testParamValues.value[paramName];
if (value === undefined || value === null) {
return 'NULL';
}
// 根据参数值类型格式化
if (typeof value === 'string') {
return `'${value}'`;
} else if (typeof value === 'boolean') {
return value ? '1' : '0';
} else {
return String(value);
}
});
// 处理 #{param} IS NOT NULL THEN ... 逻辑
// 移除参数为null的条件
const conditionPattern = /AND\s+#\{(\w+)\}\s+IS\s+NOT\s+NULL\s+THEN\s+(.+?)(?=\s+AND|#|$)/g;
sql = sql.replace(conditionPattern, (match, paramName, condition) => {
const value = testParamValues.value[paramName];
if (value !== undefined && value !== null) {
// 参数有值,保留条件(替换 #{param} 为实际值)
return `AND ${condition.replace(new RegExp(`#\\{${paramName}\\}`, 'g'), getFormattedValue(value))}`;
} else {
// 参数为null移除整个条件
return '';
}
});
return sql;
});
// 格式化参数值
function getFormattedValue(value: any): string {
if (typeof value === 'string') {
return `'${value}'`;
} else if (typeof value === 'boolean') {
return value ? '1' : '0';
} else {
return String(value);
}
}
// SQL语法验证
const validating = ref(false);
async function validateSql() {
validating.value = true;
try {
// 简单的SQL关键字验证
const sql = sqlTemplate.value.toUpperCase();
// 必须包含SELECT
if (!sql.includes('SELECT')) {
message.error('SQL模板必须包含SELECT关键字');
return;
}
// 禁止危险操作
const dangerousKeywords = ['DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'GRANT', 'INSERT', 'UPDATE'];
for (const keyword of dangerousKeywords) {
if (sql.includes(keyword)) {
message.error(`SQL模板不能包含危险关键字${keyword}`);
return;
}
}
message.success('SQL语法验证通过');
} finally {
validating.value = false;
}
}
// 复制SQL
function copySql() {
navigator.clipboard.writeText(sqlTemplate.value);
message.success('SQL模板已复制');
}
// 复制预览SQL
function copyPreviewSql() {
navigator.clipboard.writeText(previewSql.value);
message.success('参数注入后的SQL已复制');
}
// SQL模板片段快速插入
const sqlSnippets = [
{ label: 'SELECT *', value: 'SELECT *\nFROM table_name' },
{ label: 'WHERE条件', value: 'WHERE #{param} IS NOT NULL THEN field = #{param}' },
{ label: 'ORDER BY', value: 'ORDER BY field_name ASC' },
{ label: '参数占位符', value: '#{}' },
];
function insertSnippet(snippet: string) {
sqlTemplate.value += '\n' + snippet;
message.success('模板片段已插入');
}
</script>
<template>
<div class="sql-editor-container">
<Row :gutter="16">
<!-- 左侧SQL模板编辑 -->
<Col :span="12">
<Card title="SQL模板" size="small">
<template #extra>
<Space>
<Button size="small" :loading="validating" @click="validateSql">
验证SQL
</Button>
<Button size="small" @click="copySql">
复制
</Button>
</Space>
</template>
<!-- 快速插入 -->
<div class="mb-2">
<Space>
<span class="text-sm text-gray-500">快速插入</span>
<Button
v-for="snippet in sqlSnippets"
:key="snippet.label"
size="small"
type="link"
@click="insertSnippet(snippet.value)"
>
{{ snippet.label }}
</Button>
</Space>
</div>
<!-- SQL编辑器 -->
<Input.TextArea
v-model:value="sqlTemplate"
:rows="15"
placeholder="请输入SQL模板使用 #{paramName} 作为参数占位符"
class="sql-editor"
/>
<Divider />
<!-- 参数列表提示 -->
<div class="text-sm text-gray-600">
<strong>可用参数</strong>
<div v-if="paramsData.length > 0" class="mt-2">
<Space wrap>
<span v-for="param in paramsData" :key="param.paramName">
<code class="bg-gray-100 px-1 rounded">#{{ '{' + param.paramName + '}' }}</code>
<span class="text-gray-500">({{ param.paramType }})</span>
</span>
</Space>
</div>
<div v-else class="text-warning">
请在"参数配置"Tab中添加参数
</div>
</div>
</Card>
</Col>
<!-- 右侧参数注入预览 -->
<Col :span="12">
<Card title="参数注入预览" size="small">
<template #extra>
<Space>
<Button size="small" @click="copyPreviewSql">
复制预览SQL
</Button>
</Space>
</template>
<!-- 参数值输入 -->
<div v-if="paramsData.length > 0" class="mb-4">
<div class="text-sm text-gray-600 mb-2">参数值设置</div>
<div class="param-inputs">
<Row :gutter="[8, 8]">
<Col v-for="param in paramsData" :key="param.paramName" :span="12">
<div class="flex items-center">
<span class="w-24 text-sm">{{ param.paramName }}:</span>
<Input
v-model:value="testParamValues[param.paramName]"
size="small"
:placeholder="`示例: ${getExampleValue(param.paramType)}`"
/>
</div>
</Col>
</Row>
</div>
</div>
<!-- 预览SQL -->
<Input.TextArea
:value="previewSql"
:rows="15"
readonly
placeholder="设置参数值后将显示参数注入后的实际SQL"
class="sql-preview bg-gray-50"
/>
<Divider />
<!-- 说明 -->
<div class="text-sm text-gray-500">
<strong>说明</strong>
<ul class="mt-1 pl-4 list-disc">
<li>左侧编辑SQL模板使用 #{{ '{paramName}' }} 作为参数占位符</li>
<li>右侧设置参数值实时预览参数注入后的实际SQL</li>
<li>#{param} IS NOT NULL THEN ... 条件会在参数为NULL时自动移除</li>
<li>字符串类型参数会自动添加单引号</li>
</ul>
</div>
</Card>
</Col>
</Row>
</div>
</template>
<style scoped>
.sql-editor-container {
padding: 16px;
}
.sql-editor,
.sql-preview {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.text-warning {
color: #faad14;
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Popconfirm, Space, Switch, Tag, message } from 'ant-design-vue';
import { ref } from 'vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
apiConfigList,
apiConfigRemove,
apiConfigChangeStatus,
apiConfigTest,
apiConfigClearCache,
} from '#/api/erp/api';
import type { ErpApiConfigVO } from '#/api/erp/api';
import { columns, querySchema } from './data';
import TestModal from './test-modal.vue';
import DocPreviewModal from './doc-preview-modal.vue';
import ApiDrawer from './api-drawer.vue';
// 搜索表单配置
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
// 表格配置
const gridOptions: VxeGridProps<ErpApiConfigVO> = {
checkboxConfig: {
highlight: true,
reserve: true,
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
const response = await apiConfigList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
return response;
},
},
},
rowConfig: {
keyField: 'apiId',
},
id: 'erp-api-config-list',
};
// 初始化表格
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
// 测试弹窗
const [TestModalComp, testModalApi] = useVbenModal({
connectedComponent: TestModal,
});
// 文档预览弹窗
const [DocModalComp, docModalApi] = useVbenModal({
connectedComponent: DocPreviewModal,
});
// 新增/编辑Drawer
const [ApiDrawerComp, apiDrawerApi] = useVbenDrawer({
connectedComponent: ApiDrawer,
});
// 新增API配置
function handleAdd() {
apiDrawerApi.setData({});
apiDrawerApi.open();
}
// 编辑API配置
function handleEdit(record: ErpApiConfigVO) {
apiDrawerApi.setData({ apiId: record.apiId });
apiDrawerApi.open();
}
// 测试API
async function handleTest(record: ErpApiConfigVO) {
testModalApi.setData({ apiId: record.apiId, apiName: record.apiName });
testModalApi.open();
}
// 文档预览
async function handlePreview(record: ErpApiConfigVO) {
docModalApi.setData({ apiId: record.apiId, apiName: record.apiName });
docModalApi.open();
}
// 删除API配置
async function handleDelete(record: ErpApiConfigVO) {
await apiConfigRemove([record.apiId]);
message.success('删除成功');
await tableApi.query();
}
// 批量删除
async function handleMultiDelete() {
const rows = tableApi.getSelectRows();
if (!rows.length) {
message.warning('请选择要删除的数据');
return;
}
const apiIds = rows.map((row) => row.apiId);
await apiConfigRemove(apiIds);
message.success('删除成功');
await tableApi.query();
}
// 更新状态
async function handleStatusChange(record: ErpApiConfigVO, status: boolean) {
await apiConfigChangeStatus({
apiId: record.apiId,
status: status ? 1 : 0,
});
message.success('状态更新成功');
await tableApi.query();
}
// 清除缓存
async function handleClearCache(record: ErpApiConfigVO) {
await apiConfigClearCache(record.apiId);
message.success('缓存清除成功');
}
// 从表导入(待实现)
function handleImportFromTable() {
message.info('从表导入功能正在开发中');
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="API配置列表">
<template #toolbar-tools>
<Space>
<Button v-access:code="['erp:api:add']" type="primary" @click="handleAdd">
新增
</Button>
<Button v-access:code="['erp:api:add']" @click="handleImportFromTable">
从表导入
</Button>
<Button
v-access:code="['erp:api:remove']"
:disabled="!vxeCheckboxChecked(tableApi)"
danger
@click="handleMultiDelete"
>
批量删除
</Button>
</Space>
</template>
<template #pagination="{ row }">
<Tag v-if="row.supportPagination === 1" color="success"></Tag>
<Tag v-else color="default"></Tag>
</template>
<template #auth="{ row }">
<Tag v-if="row.requireAuth === 1" color="warning">需要</Tag>
<Tag v-else color="default">无需</Tag>
</template>
<template #cache="{ row }">
<Tag v-if="row.enableCache === 1" color="processing">启用</Tag>
<Tag v-else color="default">未启用</Tag>
</template>
<template #status="{ row }">
<Switch
:checked="row.status === 1"
checked-children="启用"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(row, checked)"
/>
</template>
<template #action="{ row }">
<Space>
<Button
v-access:code="['erp:api:test']"
size="small"
type="link"
@click="handleTest(row)"
>
测试
</Button>
<Button size="small" type="link" @click="handlePreview(row)">
文档
</Button>
<Button
v-access:code="['erp:api:edit']"
size="small"
type="link"
@click="handleEdit(row)"
>
编辑
</Button>
<Popconfirm
title="确认删除此API配置"
@confirm="handleDelete(row)"
>
<Button
v-access:code="['erp:api:remove']"
size="small"
type="link"
danger
>
删除
</Button>
</Popconfirm>
<Button
v-if="row.enableCache === 1"
v-access:code="['erp:api:cache']"
size="small"
type="link"
@click="handleClearCache(row)"
>
清缓存
</Button>
</Space>
</template>
</BasicTable>
<TestModalComp @success="tableApi.query()" />
<DocModalComp />
<ApiDrawerComp @reload="tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, DescriptionsItem, Input, message, Spin, Alert, Tabs, TabPane } from 'ant-design-vue';
import { ref, computed } from 'vue';
import { apiConfigTest, apiConfigInfo } from '#/api/erp/api';
import type { ApiTestResultVO, ErpApiParam } from '#/api/erp/api';
const emit = defineEmits<{
success: [];
}>();
const apiId = ref<number>();
const apiName = ref<string>();
const params = ref<Record<string, any>>({});
const testResult = ref<ApiTestResultVO>();
const loading = ref(false);
const apiParamsConfig = ref<ErpApiParam[]>([]);
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (!isOpen) {
resetState();
return;
}
const data = modalApi.getData() as { apiId: number; apiName: string };
apiId.value = data.apiId;
apiName.value = data.apiName;
// 加载参数配置
try {
const response = await apiConfigInfo(data.apiId);
apiParamsConfig.value = response.params || [];
// 初始化参数默认值
const defaultParams: Record<string, any> = {};
apiParamsConfig.value.forEach((param) => {
if (param.defaultValue) {
defaultParams[param.paramName] = param.defaultValue;
}
});
params.value = defaultParams;
} catch (error) {
message.error('加载参数配置失败');
}
},
});
function resetState() {
testResult.value = undefined;
params.value = {};
loading.value = false;
}
async function handleExecute() {
if (!apiId.value) return;
loading.value = true;
testResult.value = undefined;
try {
const result = await apiConfigTest(apiId.value, params.value);
testResult.value = result;
if (result.success) {
message.success('测试成功');
} else {
message.error('测试失败');
}
} catch (error: any) {
message.error('测试执行失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false;
}
}
const resultJson = computed(() => {
if (!testResult.value?.data) return '';
return JSON.stringify(testResult.value.data, null, 2);
});
</script>
<template>
<Modal
:title="`API测试 - ${apiName}`"
class="w-[800px]"
:footer="null"
>
<div class="test-container">
<!-- 参数输入区 -->
<div class="params-section mb-4">
<h4 class="mb-2">输入参数</h4>
<div v-if="apiParamsConfig.length > 0" class="param-inputs">
<div v-for="param in apiParamsConfig" :key="param.paramId" class="param-item">
<label class="mb-1">{{ param.paramName }} ({{ param.paramType }})</label>
<Input
v-model:value="params[param.paramName]"
:placeholder="param.paramDesc || `请输入${param.paramName}`"
class="mb-2"
/>
</div>
</div>
<div v-else>
<Alert type="info" message="此API无参数配置可直接执行" />
</div>
<Button type="primary" :loading="loading" @click="handleExecute" class="mt-2">
执行测试
</Button>
</div>
<!-- 执行结果区 -->
<div v-if="testResult" class="result-section">
<h4 class="mb-2">执行结果</h4>
<!-- 结果元信息 -->
<Descriptions bordered :column="2" class="mb-3">
<DescriptionsItem label="执行状态">
<Alert :type="testResult.success ? 'success' : 'error'">
{{ testResult.success ? '成功' : '失败' }}
</Alert>
</DescriptionsItem>
<DescriptionsItem label="执行时间">
{{ testResult.executionTime }} ms
</DescriptionsItem>
<DescriptionsItem label="API路径" :span="2">
{{ testResult.apiPath }}
</DescriptionsItem>
<DescriptionsItem label="请求参数" :span="2">
<pre class="params-output">{{ JSON.stringify(testResult.requestParams, null, 2) }}</pre>
</DescriptionsItem>
</Descriptions>
<!-- 成功结果 -->
<div v-if="testResult.success">
<Tabs>
<TabPane key="data" tab="返回数据">
<pre class="json-output">{{ resultJson }}</pre>
</TabPane>
<TabPane key="sql" tab="执行的SQL">
<pre class="sql-output">{{ testResult.executedSql }}</pre>
</TabPane>
</Tabs>
</div>
<!-- 错误信息 -->
<div v-else>
<Alert type="error" class="mb-2">
<template #message>
<strong>错误信息</strong>
<div>{{ testResult.errorMessage }}</div>
</template>
</Alert>
<details>
<summary>查看详细错误堆栈</summary>
<pre class="error-stack">{{ testResult.errorStack }}</pre>
</details>
</div>
</div>
<Spin v-else-if="loading" class="loading-spinner" />
</div>
</Modal>
</template>
<style scoped>
.test-container {
max-height: 600px;
overflow-y: auto;
}
.param-item {
margin-bottom: 12px;
}
.error-stack {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.loading-spinner {
display: flex;
justify-content: center;
padding: 40px;
}
.json-output,
.sql-output,
.params-output {
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
max-height: 400px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { Modal, Descriptions, Tag, Divider, Alert, Collapse, CollapsePanel } from 'ant-design-vue';
import { computed } from 'vue';
interface ErrorDetail {
statsId: number;
apiId: number;
callTime: string;
callParams: string;
executedSql: string;
responseTime: number;
callStatus: string;
errorMessage: string;
errorStack: string;
clientIp: string;
userId: string;
}
const props = defineProps<{
visible: boolean;
record: ErrorDetail | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'close'): void;
}>();
const modalVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
});
function handleClose() {
emit('close');
}
// 格式化JSON参数
function formatParams(paramsStr: string) {
try {
const params = JSON.parse(paramsStr);
return JSON.stringify(params, null, 2);
} catch {
return paramsStr;
}
}
// 简化错误堆栈只显示前10行
function simplifyStack(stack: string) {
if (!stack) return '';
const lines = stack.split('\n').slice(0, 10);
return lines.join('\n');
}
</script>
<template>
<Modal
v-model:open="modalVisible"
title="错误详情"
width="80%"
:footer="null"
@cancel="handleClose"
>
<div v-if="record">
<!-- 基本信息 -->
<Descriptions title="调用信息" bordered :column="2" size="small" class="mb-4">
<Descriptions.Item label="调用时间">
{{ record.callTime }}
</Descriptions.Item>
<Descriptions.Item label="响应时间">
<Tag color="error">{{ record.responseTime }}ms</Tag>
</Descriptions.Item>
<Descriptions.Item label="客户端IP">
{{ record.clientIp }}
</Descriptions.Item>
<Descriptions.Item label="用户ID">
{{ record.userId }}
</Descriptions.Item>
<Descriptions.Item label="调用状态">
<Tag color="error">ERROR</Tag>
</Descriptions.Item>
<Descriptions.Item label="统计ID">
{{ record.statsId }}
</Descriptions.Item>
</Descriptions>
<Divider />
<!-- 错误信息 -->
<Alert
type="error"
:message="record.errorMessage"
class="mb-4"
show-icon
/>
<!-- 调用参数 -->
<Collapse class="mb-4">
<CollapsePanel key="params" header="调用参数">
<pre class="code-block">{{ formatParams(record.callParams) }}</pre>
</CollapsePanel>
</Collapse>
<!-- 执行的SQL -->
<Collapse class="mb-4">
<CollapsePanel key="sql" header="执行的SQL">
<pre class="code-block">{{ record.executedSql }}</pre>
</CollapsePanel>
</Collapse>
<!-- 错误堆栈 -->
<Collapse>
<CollapsePanel key="stack" header="错误堆栈前10行">
<pre class="code-block error-stack">{{ simplifyStack(record.errorStack) }}</pre>
</CollapsePanel>
</Collapse>
</div>
<Alert v-else type="warning" message="未加载错误详情数据" />
</Modal>
</template>
<style scoped>
.mb-4 {
margin-bottom: 16px;
}
.code-block {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.error-stack {
color: #cf1322;
background: #fff1f0;
}
</style>

View File

@@ -0,0 +1,588 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import {
Card,
Col,
Row,
Statistic,
Table,
Tabs,
TabPane,
DatePicker,
Space,
Tag,
Progress,
Empty,
Alert,
Divider,
Select,
Button,
Spin,
message,
Switch,
InputNumber,
Tooltip,
} from 'ant-design-vue';
import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { apiConfigList, apiConfigStats, apiConfigErrorLog } from '#/api/erp/api';
import type { ErpApiConfigVO } from '#/api/erp/api';
import ErrorDetailModal from './error-detail-modal.vue';
// 时间范围选择
const timeRange = ref<any[]>([]);
const startTime = computed(() => timeRange.value[0] || undefined);
const endTime = computed(() => timeRange.value[1] || undefined);
// API列表用于选择查看统计
const apiList = ref<ErpApiConfigVO[]>([]);
const selectedApiId = ref<number>();
// Loading状态
const loading = ref(false);
// 慢查询阈值(毫秒)
const slowQueryThreshold = ref<number>(1000);
// 自动刷新配置
const autoRefreshEnabled = ref(false);
const refreshInterval = ref<number>(30); // 刷新间隔(秒)
let refreshTimer: number | null = null;
// 错误详情弹窗
const errorDetailVisible = ref(false);
const selectedErrorRecord = ref<any>(null);
// 加载API列表
onMounted(async () => {
try {
const response = await apiConfigList({ pageNum: 1, pageSize: 100 });
apiList.value = response.rows || [];
if (apiList.value.length > 0) {
selectedApiId.value = apiList.value[0].apiId;
await loadStats();
}
} catch (error) {
console.error('加载API列表失败', error);
message.error('加载API列表失败');
}
});
// 统计数据
const statsData = ref({
totalCalls: 0,
successCalls: 0,
errorCalls: 0,
avgResponseTime: 0,
maxResponseTime: 0,
minResponseTime: 0,
errorRate: 0,
slowCalls: 0, // 慢查询数量
cacheEnabled: false, // 是否启用缓存
});
// 加载统计数据
async function loadStats() {
if (!selectedApiId.value) {
message.warning('请先选择API');
return;
}
loading.value = true;
try {
const stats = await apiConfigStats(
selectedApiId.value,
startTime.value,
endTime.value
);
console.log('统计数据:', stats);
// 获取选中的API配置检查是否启用缓存
const selectedApi = apiList.value.find(api => api.apiId === selectedApiId.value);
statsData.value = {
totalCalls: stats.totalCalls || 0,
successCalls: stats.successCalls || 0,
errorCalls: stats.errorCalls || 0,
avgResponseTime: stats.avgResponseTime || 0,
maxResponseTime: stats.maxResponseTime || 0,
minResponseTime: stats.minResponseTime || 0,
errorRate: stats.errorRate || 0,
slowCalls: stats.slowCalls || 0, // TODO: 后端需要添加慢查询统计
cacheEnabled: selectedApi?.enableCache === 1,
};
// 同时加载错误日志
await loadErrorLog();
message.success('统计数据已更新');
} catch (error) {
console.error('加载统计数据失败', error);
message.error('加载统计数据失败');
} finally {
loading.value = false;
}
}
// 错误日志表格配置
const errorLogColumns = [
{ title: '调用时间', dataIndex: 'callTime', width: 180 },
{ title: '响应时间', dataIndex: 'responseTime', width: 100, customRender: ({ text }) => `${text}ms` },
{ title: '客户端IP', dataIndex: 'clientIp', width: 150 },
{ title: '用户ID', dataIndex: 'userId', width: 120 },
{ title: '错误消息', dataIndex: 'errorMessage', ellipsis: true, width: 200 },
{ title: '操作', dataIndex: 'action', width: 80, fixed: 'right' as const },
];
const errorLogData = ref<any[]>([]);
// 加载错误日志
async function loadErrorLog() {
if (!selectedApiId.value) return;
try {
const logs = await apiConfigErrorLog(selectedApiId.value, 50);
console.log('错误日志:', logs);
errorLogData.value = logs || [];
} catch (error) {
console.error('加载错误日志失败', error);
errorLogData.value = [];
}
}
// API选择变化
function handleApiChange(apiId: number) {
selectedApiId.value = apiId;
}
// 查询按钮
function handleQuery() {
loadStats();
}
// 刷新按钮
async function handleRefresh() {
message.info('正在刷新统计数据...');
await loadStats();
}
// 重置时间范围
function handleResetTimeRange() {
timeRange.value = [];
loadStats();
}
// 查看错误详情
function viewErrorDetail(record: any) {
selectedErrorRecord.value = record;
errorDetailVisible.value = true;
}
// 关闭错误详情弹窗
function closeErrorDetail() {
errorDetailVisible.value = false;
selectedErrorRecord.value = null;
}
// 开启/关闭自动刷新
function toggleAutoRefresh(enabled: boolean) {
autoRefreshEnabled.value = enabled;
if (enabled) {
startAutoRefresh();
message.success(`已开启自动刷新,每${refreshInterval.value}秒刷新一次`);
} else {
stopAutoRefresh();
message.info('已关闭自动刷新');
}
}
// 启动自动刷新定时器
function startAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
}
refreshTimer = window.setInterval(() => {
if (selectedApiId.value && !loading.value) {
loadStats();
}
}, refreshInterval.value * 1000);
}
// 停止自动刷新
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
// 监听刷新间隔变化
watch(refreshInterval, (newInterval) => {
if (autoRefreshEnabled.value) {
stopAutoRefresh();
startAutoRefresh();
}
});
// 组件卸载时清理定时器
onMounted(() => {
// ... existing code
});
onUnmounted(() => {
stopAutoRefresh();
});
// 当前Tab
const currentTab = ref('overview');
// 汇总统计所有API- 使用computed自动计算
const overallStats = computed(() => {
return {
totalApis: apiList.value.length,
enabledApis: apiList.value.filter(api => api.status === 1).length,
disabledApis: apiList.value.filter(api => api.status === 0).length,
cachedApis: apiList.value.filter(api => api.enableCache === 1).length,
};
});
// 计算性能健康度0-100
function calculateHealthScore(): number {
if (statsData.value.totalCalls === 0) return 100;
// 综合考虑错误率和响应时间
const errorScore = Math.max(0, 100 - statsData.value.errorRate * 10); // 错误率影响每1%扣10分
const timeScore = Math.max(0, 100 - (statsData.value.avgResponseTime / slowQueryThreshold.value) * 20); // 响应时间影响
return Math.round((errorScore + timeScore) / 2);
}
// 获取健康度颜色
function getHealthColor(): string {
const score = calculateHealthScore();
if (score >= 80) return '#3f8600'; // 优秀
if (score >= 60) return '#faad14'; // 一般
return '#cf1322'; // 差
}
// 获取健康度描述
function getHealthDescription(): string {
const score = calculateHealthScore();
if (score >= 80) return '性能优秀,系统运行正常';
if (score >= 60) return '性能一般,建议优化';
if (score >= 40) return '性能较差,需要优化';
return '性能很差,紧急优化';
}
// 计算响应时间评分0-100
function calculateResponseTimeScore(): number {
if (statsData.value.maxResponseTime === 0) return 100;
const avgRatio = statsData.value.avgResponseTime / slowQueryThreshold.value;
return Math.max(0, Math.min(100, 100 - avgRatio * 50));
}
// 获取响应时间颜色
function getResponseTimeColor(): string {
const score = calculateResponseTimeScore();
if (score >= 80) return '#3f8600';
if (score >= 60) return '#1890ff';
if (score >= 40) return '#faad14';
return '#cf1322';
}
</script>
<template>
<Page :auto-content-height="true">
<!-- 上方汇总统计卡片 -->
<Card title="API概览" size="small" class="mb-4">
<Row :gutter="16">
<Col :span="6">
<Statistic title="API总数" :value="overallStats.totalApis" />
</Col>
<Col :span="6">
<Statistic title="启用API" :value="overallStats.enabledApis" :value-style="{ color: '#3f8600' }" />
</Col>
<Col :span="6">
<Statistic title="禁用API" :value="overallStats.disabledApis" :value-style="{ color: '#cf1322' }" />
</Col>
<Col :span="6">
<Statistic title="启用缓存" :value="overallStats.cachedApis" :value-style="{ color: '#1890ff' }" />
</Col>
</Row>
</Card>
<!-- 下方详细统计 -->
<Card size="small">
<!-- API选择和时间范围 -->
<div class="filter-section mb-4">
<Space wrap>
<span>选择API</span>
<Select
v-model:value="selectedApiId"
style="width: 300px"
placeholder="请选择API"
>
<Select.Option v-for="api in apiList" :key="api.apiId" :value="api.apiId">
{{ api.apiName }} ({{ api.apiPath }})
</Select.Option>
</Select>
<span>时间范围</span>
<DatePicker.RangePicker
v-model:value="timeRange"
style="width: 240px"
/>
<Button type="primary" :loading="loading" @click="handleQuery">
查询
</Button>
<Button :loading="loading" @click="handleRefresh">
刷新
</Button>
<Button @click="handleResetTimeRange">
重置
</Button>
<Divider type="vertical" />
<Tooltip title="开启后将按设定的间隔自动刷新统计数据">
<Space>
<span>自动刷新</span>
<Switch v-model:checked="autoRefreshEnabled" @change="toggleAutoRefresh" />
</Space>
</Tooltip>
<Tooltip title="自动刷新的时间间隔(秒)">
<Space v-if="autoRefreshEnabled">
<span>间隔</span>
<InputNumber
v-model:value="refreshInterval"
:min="10"
:max="300"
:step="10"
style="width: 80px"
/>
<span></span>
</Space>
</Tooltip>
<Tooltip title="响应时间超过此阈值的调用视为慢查询">
<Space>
<span>慢查询阈值</span>
<InputNumber
v-model:value="slowQueryThreshold"
:min="100"
:max="10000"
:step="100"
style="width: 100px"
/>
<span>ms</span>
</Space>
</Tooltip>
</Space>
</div>
<!-- Loading提示 -->
<Spin :spinning="loading" tip="正在加载统计数据...">
<!-- 统计卡片 -->
<Row :gutter="16" class="mb-4">
<Col :span="3">
<Statistic title="总调用次数" :value="statsData.totalCalls" />
</Col>
<Col :span="3">
<Statistic title="成功次数" :value="statsData.successCalls" :value-style="{ color: '#3f8600' }" />
</Col>
<Col :span="3">
<Statistic title="错误次数" :value="statsData.errorCalls" :value-style="{ color: '#cf1322' }" />
</Col>
<Col :span="3">
<Statistic title="错误率" :value="statsData.errorRate.toFixed(2)" suffix="%" :value-style="{ color: statsData.errorRate > 5 ? '#cf1322' : '#3f8600' }" />
</Col>
<Col :span="3">
<Statistic title="平均响应时间" :value="statsData.avgResponseTime" suffix="ms" :value-style="{ color: statsData.avgResponseTime > slowQueryThreshold ? '#faad14' : '#1890ff' }" />
</Col>
<Col :span="3">
<Statistic title="最大响应时间" :value="statsData.maxResponseTime" suffix="ms" :value-style="{ color: statsData.maxResponseTime > slowQueryThreshold ? '#cf1322' : '#1890ff' }" />
</Col>
<Col :span="3">
<Statistic title="最小响应时间" :value="statsData.minResponseTime" suffix="ms" />
</Col>
<Col :span="3">
<Statistic title="慢查询次数" :value="statsData.slowCalls" :value-style="{ color: '#faad14' }">
<template #suffix>
<Tooltip title="响应时间超过阈值的调用">
<Tag color="warning">>{{ slowQueryThreshold }}ms</Tag>
</Tooltip>
</template>
</Statistic>
</Col>
</Row>
<!-- 性能指标卡片 -->
<Row :gutter="16" class="mb-4">
<Col :span="6">
<Card size="small" title="性能健康度" :bordered="false">
<Progress
:percent="calculateHealthScore()"
:stroke-color="getHealthColor()"
trail-color="#f0f0f0"
/>
<div class="metric-desc">{{ getHealthDescription() }}</div>
</Card>
</Col>
<Col :span="6">
<Card size="small" title="成功率" :bordered="false">
<Progress
:percent="100 - statsData.errorRate"
:stroke-color="{ '0%': '#87d068', '100%': '#3f8600' }"
trail-color="#f0f0f0"
/>
<div class="metric-desc">成功 {{ statsData.successCalls }} / 总计 {{ statsData.totalCalls }}</div>
</Card>
</Col>
<Col :span="6">
<Card size="small" title="响应时间分布" :bordered="false">
<Progress
:percent="calculateResponseTimeScore()"
:stroke-color="getResponseTimeColor()"
trail-color="#f0f0f0"
/>
<div class="metric-desc">平均 {{ statsData.avgResponseTime }}ms / 最大 {{ statsData.maxResponseTime }}ms</div>
</Card>
</Col>
<Col :span="6">
<Card size="small" title="缓存状态" :bordered="false">
<Statistic :value="statsData.cacheEnabled ? '已启用' : '未启用'" :value-style="{ color: statsData.cacheEnabled ? '#3f8600' : '#8c8c8c' }" />
<div class="metric-desc">缓存可提升查询性能</div>
</Card>
</Col>
</Row>
<!-- 错误率进度条 -->
<div class="mb-4">
<Progress
:percent="100 - statsData.errorRate"
:stroke-color="{ '0%': '#3f8600', '100%': '#87d068' }"
:trail-color="statsData.errorRate > 10 ? '#cf1322' : '#f0f0f0'"
/>
</div>
<!-- Tabs切换 -->
<Tabs v-model:activeKey="currentTab">
<TabPane key="overview" tab="统计概览">
<Empty v-if="!selectedApiId" description="请选择API查看统计" />
<Alert v-else-if="statsData.totalCalls === 0" type="info">
<template #message>
<div>
<strong>该API暂无调用记录</strong>
<br />
<small>请先调用动态API触发统计记录然后刷新监控页面查看数据</small>
</div>
</template>
</Alert>
<div v-else>
<Alert type="success" message="统计功能已完善包含性能健康度成功率响应时间分布等多维度分析" />
<!-- 性能建议 -->
<Card size="small" title="性能优化建议" class="mt-4" v-if="statsData.totalCalls > 0">
<div v-if="statsData.errorRate > 5">
<Tag color="error">高错误率</Tag>
<span>错误率超过5%建议检查API配置和SQL语句</span>
</div>
<div v-if="statsData.avgResponseTime > slowQueryThreshold">
<Tag color="warning">慢查询较多</Tag>
<span>平均响应时间超过阈值建议优化SQL查询或启用缓存</span>
</div>
<div v-if="!statsData.cacheEnabled && statsData.totalCalls > 10">
<Tag color="info">未启用缓存</Tag>
<span>调用次数较多但未启用缓存,建议启用缓存以提升性能</span>
</div>
<div v-if="statsData.errorRate <= 1 && statsData.avgResponseTime <= slowQueryThreshold / 2">
<Tag color="success">性能优秀</Tag>
<span>API运行状态良好继续保持</span>
</div>
</Card>
</div>
</TabPane>
<TabPane key="errorLog" tab="错误日志">
<Empty v-if="errorLogData.length === 0" description="暂无错误日志" />
<div v-else>
<Alert type="warning" class="mb-2">
<template #message>
<span>最近发现 {{ errorLogData.length }} 条错误记录,建议及时处理</span>
</template>
</Alert>
<Table
:columns="errorLogColumns"
:data-source="errorLogData"
:pagination="{ pageSize: 20 }"
size="small"
:scroll="{ x: 1000 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'callTime'">
{{ record.callTime }}
</template>
<template v-if="column.dataIndex === 'errorMessage'">
<Tooltip :title="record.errorMessage">
<Tag color="error">{{ record.errorMessage.substring(0, 50) }}...</Tag>
</Tooltip>
</template>
<template v-if="column.dataIndex === 'action'">
<Button type="link" size="small" @click="viewErrorDetail(record)">
详情
</Button>
</template>
</template>
</Table>
</div>
</TabPane>
</Tabs>
</Spin>
</Card>
<!-- 错误详情弹窗 -->
<ErrorDetailModal
v-model:visible="errorDetailVisible"
:record="selectedErrorRecord"
@close="closeErrorDetail"
/>
</Page>
</template>
<style scoped>
.filter-section {
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
.mb-4 {
margin-bottom: 16px;
}
.mb-2 {
margin-bottom: 8px;
}
.mt-4 {
margin-top: 16px;
}
.metric-desc {
font-size: 12px;
color: #8c8c8c;
margin-top: 8px;
}
</style>

View File

@@ -4,7 +4,7 @@ import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { Dept } from '#/api/system/dept/model';
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { eachTree, getVxePopupContainer } from '@vben/utils';
@@ -12,7 +12,9 @@ import { eachTree, getVxePopupContainer } from '@vben/utils';
import { Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deptList, deptRemove } from '#/api/system/dept';
import { deptList, deptRemove, deptSyncFromWecom } from '#/api/system/dept';
import { message } from 'ant-design-vue';
import { columns, querySchema } from './data';
import deptDrawer from './dept-drawer.vue';
@@ -95,6 +97,8 @@ const [DeptDrawer, drawerApi] = useVbenDrawer({
connectedComponent: deptDrawer,
});
const syncLoading = ref(false);
function handleAdd() {
drawerApi.setData({ update: false });
drawerApi.open();
@@ -116,6 +120,23 @@ async function handleDelete(row: Dept) {
await tableApi.query();
}
async function handleSyncFromWecom() {
if (syncLoading.value) return;
syncLoading.value = true;
const loadingMsg = message.loading('正在从企业微信同步部门,请稍候...', 0);
try {
const result = await deptSyncFromWecom();
loadingMsg();
message.success(result || '同步成功');
await tableApi.query();
} catch {
loadingMsg();
message.error('同步失败');
} finally {
syncLoading.value = false;
}
}
/**
* 全部展开/折叠
* @param expand 是否展开
@@ -137,6 +158,13 @@ function setExpandOrCollapse(expand: boolean) {
<a-button @click="setExpandOrCollapse(true)">
{{ $t('pages.common.expand') }}
</a-button>
<a-button
:loading="syncLoading"
v-access:code="['system:dept:edit']"
@click="handleSyncFromWecom"
>
从企业微信同步
</a-button>
<a-button
type="primary"
v-access:code="['system:dept:add']"

View File

@@ -28,10 +28,13 @@ import {
userList,
userRemove,
userStatusChange,
userSyncFromWecom,
} from '#/api/system/user';
import { TableSwitch } from '#/components/table';
import { commonDownloadExcel } from '#/utils/file/download';
import { message } from 'ant-design-vue';
import { columns, querySchema } from './data';
import DeptTree from './dept-tree.vue';
import userDrawer from './user-drawer.vue';
@@ -52,6 +55,7 @@ function handleImport() {
// 左边部门用
const selectDeptId = ref<string[]>([]);
const syncLoading = ref(false);
const formOptions: VbenFormProps = {
schema: querySchema(),
@@ -168,6 +172,23 @@ function handleDownloadExcel() {
});
}
async function handleSyncFromWecom() {
if (syncLoading.value) return;
syncLoading.value = true;
const loadingMsg = message.loading('正在从企业微信同步用户,请稍候...', 0);
try {
const result = await userSyncFromWecom();
loadingMsg();
message.success(result || '同步成功');
await tableApi.query();
} catch (error: any) {
loadingMsg();
message.error(error?.message || '同步失败');
} finally {
syncLoading.value = false;
}
}
const [UserInfoModal, userInfoModalApi] = useVbenModal({
connectedComponent: userInfoModal,
});
@@ -228,6 +249,13 @@ const { hasAccessByCodes } = useAccess();
>
{{ $t('pages.common.add') }}
</a-button>
<a-button
:loading="syncLoading"
v-access:code="['system:user:edit']"
@click="handleSyncFromWecom"
>
从企业微信同步
</a-button>
</Space>
</template>
<template #avatar="{ row }">

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { h, onMounted, onUnmounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Modal,
Select,
Space,
Switch,
Table,
Tag,
message,
} from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import {
getTaskStatus,
setCron as setCronApi,
startTask,
stopTask,
wecomApprovalSyncFull,
wecomApprovalSyncLogs,
wecomApprovalSyncTemplates,
} from '#/api/system/wecom-approval-sync';
defineOptions({ name: 'WecomApprovalSync' });
const syncing = ref(false);
const syncingTemplates = ref(false);
const syncDays = ref(30);
const logs = ref<any[]>([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
const latestSyncLogId = ref<number | null>(null);
// 同步状态
const statusMap: Record<string, { text: string; color: string }> = {
RUNNING: { text: '同步中', color: 'processing' },
COMPLETED: { text: '已完成', color: 'success' },
FAILED: { text: '失败', color: 'error' },
};
// 定时任务状态
const taskRunning = ref(false);
const currentCron = ref('0 0/5 * * * ?');
const taskLoading = ref(false);
const cronOptions = [
{ label: '每 1 分钟', value: '0 0/1 * * * ?' },
{ label: '每 5 分钟', value: '0 0/5 * * * ?' },
{ label: '每 10 分钟', value: '0 0/10 * * * ?' },
{ label: '每 30 分钟', value: '0 0/30 * * * ?' },
{ label: '每 1 小时', value: '0 0 * * * ?' },
{ label: '每天凌晨 2 点', value: '0 0 2 * * ?' },
];
const syncTypeMap: Record<string, { text: string; color: string }> = {
FULL: { text: '全量同步', color: 'blue' },
INCREMENTAL: { text: '增量同步', color: 'green' },
MANUAL: { text: '手动同步', color: 'orange' },
};
const columns = [
{
title: '状态',
key: 'status',
width: 100,
customRender: ({ record }: any) => {
const info = statusMap[record.status] || { text: '未知', color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{
title: '同步类型',
dataIndex: 'syncTypeText',
key: 'syncTypeText',
width: 120,
customRender: ({ record }: any) => {
const info = syncTypeMap[record.syncType] || { text: record.syncType, color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{ title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 180 },
{ title: '结束时间', dataIndex: 'endTime', key: 'endTime', width: 180 },
{ title: '耗时(秒)', dataIndex: 'duration', key: 'duration', width: 100 },
{ title: '新增', dataIndex: 'addedCount', key: 'addedCount', width: 80 },
{ title: '更新', dataIndex: 'updatedCount', key: 'updatedCount', width: 80 },
{ title: '失败', dataIndex: 'errorCount', key: 'errorCount', width: 80 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
];
async function handleFullSync() {
Modal.confirm({
title: '确认全量同步',
icon: () => h(ExclamationCircleOutlined),
content: `将同步近 ${syncDays.value} 天的所有审批数据,后台异步执行,请查看同步日志跟踪进度。确定继续吗?`,
okText: '确定同步',
cancelText: '取消',
async onOk() {
syncing.value = true;
try {
const logId = await wecomApprovalSyncFull(syncDays.value);
latestSyncLogId.value = logId;
message.success('已启动同步,请在下方同步日志中查看进度');
await loadLogs();
// 如果正在运行,启动轮询
startPolling();
} catch {
message.error('启动同步失败');
} finally {
syncing.value = false;
}
},
});
}
function startPolling() {
stopPolling();
pollingTimer.value = setInterval(async () => {
await loadLogs();
// 检查最新同步日志是否还在运行
const runningLog = logs.value.find((l: any) => l.status === 'RUNNING');
if (!runningLog) {
stopPolling();
}
}, 3000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
async function loadLogs() {
loading.value = true;
try {
const res = await wecomApprovalSyncLogs({
pageNum: currentPage.value ?? 1,
pageSize: pageSize.value ?? 10,
});
logs.value = res?.rows ?? [];
total.value = res?.total ?? 0;
} catch (error: any) {
const errorMsg = error?.message || error?.response?.data?.msg || '未知错误';
message.error(`加载同步日志失败: ${errorMsg}`);
} finally {
loading.value = false;
}
}
async function loadTaskStatus() {
try {
const status = await getTaskStatus();
taskRunning.value = status.running;
currentCron.value = status.cron || '0 0/5 * * * ?';
} catch { /* 忽略 */ }
}
async function handleTaskToggle(checked: boolean) {
taskLoading.value = true;
try {
if (checked) {
await startTask();
message.success('定时任务已启动');
} else {
await stopTask();
message.success('定时任务已停止');
}
await loadTaskStatus();
} catch {
message.error(checked ? '启动失败' : '停止失败');
} finally {
taskLoading.value = false;
}
}
async function handleCronChange(value: string) {
try {
await setCronApi(value);
message.success('同步频率已更新');
currentCron.value = value;
} catch {
message.error('更新频率失败');
}
}
function handlePageChange(pagination: any) {
currentPage.value = pagination.current ?? 1;
pageSize.value = pagination.pageSize ?? 10;
loadLogs();
}
async function handleSyncTemplates() {
Modal.confirm({
title: '确认同步模板',
icon: () => h(ExclamationCircleOutlined),
content: '将从已同步的审批数据中提取模板ID并同步模板详情确定继续吗',
okText: '确定',
cancelText: '取消',
async onOk() {
syncingTemplates.value = true;
try {
await wecomApprovalSyncTemplates();
message.success('模板同步完成');
} catch {
message.error('模板同步失败');
} finally {
syncingTemplates.value = false;
}
},
});
}
onMounted(() => {
loadLogs();
loadTaskStatus();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<Page :auto-content-height="true">
<div style="overflow-y: auto; padding: 16px">
<!-- 操作区 -->
<Card title="操作" :bordered="false" style="margin-bottom: 16px">
<Space>
<span>同步近</span>
<Select v-model:value="syncDays" style="width: 120px">
<Select.Option :value="1">1 </Select.Option>
<Select.Option :value="7">7 </Select.Option>
<Select.Option :value="30">30 </Select.Option>
<Select.Option :value="90">90 </Select.Option>
</Select>
<span>的数据</span>
<Button type="primary" danger :loading="syncing" @click="handleFullSync">
全量同步
</Button>
<Button :loading="syncingTemplates" @click="handleSyncTemplates">
同步模板
</Button>
</Space>
</Card>
<!-- 定时任务管理 -->
<Card title="定时任务" :bordered="false" style="margin-bottom: 16px">
<Space>
<span>启用定时同步</span>
<Switch
v-model:checked="taskRunning"
:loading="taskLoading"
checked-children=""
un-checked-children=""
@change="handleTaskToggle"
/>
<span style="margin-left: 24px">同步频率</span>
<Select
:value="currentCron"
style="width: 180px"
:options="cronOptions"
@change="handleCronChange"
/>
<Tag :color="taskRunning ? 'green' : 'default'">
{{ taskRunning ? '运行中' : '已停止' }}
</Tag>
</Space>
</Card>
<!-- 同步日志表格 -->
<Card title="同步日志" :bordered="false">
<Table
:columns="columns"
:data-source="logs"
:loading="loading"
:pagination="{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
row-key="id"
@change="handlePageChange"
/>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { Rule } from 'ant-design-vue/es/form';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Alert,
Button,
Card,
Divider,
Form,
FormItem,
Input,
InputPassword,
message,
Space,
Switch,
Textarea,
} from 'ant-design-vue';
import { LinkOutlined } from '@ant-design/icons-vue';
import { getWecomConfig, saveWecomConfig, testWecomConfig } from '#/api/system/wecom-config';
const saving = ref(false);
const testing = ref(false);
const formRef = ref();
const formState = reactive({
corpid: '',
corpsecret: '',
agentId: '',
callbackToken: '',
callbackAesKey: '',
enabled: false,
remark: '',
});
const rules: Record<string, Rule[]> = {
corpid: [{ required: true, message: '请输入企业ID', trigger: 'blur' }],
corpsecret: [{ required: true, message: '请输入应用密钥', trigger: 'blur' }],
};
async function loadConfig() {
try {
const data = await getWecomConfig();
if (data) {
formState.corpid = data.corpid || '';
formState.corpsecret = data.corpsecret || '';
formState.agentId = data.agentId || '';
formState.callbackToken = data.callbackToken || '';
formState.callbackAesKey = data.callbackAesKey || '';
formState.enabled = data.enabled === '1';
formState.remark = data.remark || '';
}
} catch {
// ignore
}
}
async function handleSave() {
try {
await formRef.value?.validate();
} catch {
return;
}
saving.value = true;
try {
await saveWecomConfig({
...formState,
enabled: formState.enabled ? '1' : '0',
});
message.success('保存成功');
} catch {
message.error('保存失败');
} finally {
saving.value = false;
}
}
async function handleTest() {
if (!formState.corpid || !formState.corpsecret) {
message.error('企业ID和应用密钥不能为空');
return;
}
testing.value = true;
try {
const result = await testWecomConfig(formState.corpid, formState.corpsecret);
message.success(result || '连接成功');
} catch {
message.error('测试失败');
} finally {
testing.value = false;
}
}
function openWeComHelp() {
window.open('https://work.weixin.qq.com/wework_admin/frame#profile', '_blank');
}
onMounted(() => {
loadConfig();
});
</script>
<template>
<Page>
<div style="overflow-y: auto; padding: 16px">
<Card title="企业微信配置" :bordered="false" style="max-width: 720px">
<template #extra>
<Button type="link" @click="openWeComHelp">
<template #icon><LinkOutlined /></template>
如何获取
</Button>
</template>
<Alert
message="请在企业微信管理后台获取企业ID和应用密钥"
type="info"
show-icon
style="margin-bottom: 24px"
/>
<Form ref="formRef" :model="formState" layout="vertical" style="max-width: 480px">
<FormItem label="企业IDcorpid" name="corpid" :rules="rules.corpid">
<Input v-model:value="formState.corpid" placeholder="从企业微信管理后台获取" />
</FormItem>
<FormItem label="应用密钥corpsecret" name="corpsecret" :rules="rules.corpsecret">
<InputPassword v-model:value="formState.corpsecret" placeholder="自建应用的Secret" />
</FormItem>
<FormItem label="应用 AgentID" name="agentId">
<Input v-model:value="formState.agentId" placeholder="可选,应用管理页面获取" />
</FormItem>
<Divider>回调配置可选</Divider>
<FormItem label="回调 Token" name="callbackToken">
<Input v-model:value="formState.callbackToken" placeholder="审批回调URL验证Token" />
</FormItem>
<FormItem label="回调 EncodingAESKey" name="callbackAesKey">
<InputPassword v-model:value="formState.callbackAesKey" placeholder="消息加密密钥" />
</FormItem>
<FormItem label="启用状态" name="enabled">
<Switch v-model:checked="formState.enabled" checked-children="已启用" un-checked-children="未启用" />
</FormItem>
<FormItem label="备注" name="remark">
<Textarea v-model:value="formState.remark" :rows="2" placeholder="备注信息" />
</FormItem>
<FormItem>
<Space>
<Button :loading="testing" @click="handleTest">
测试连接
</Button>
<Button type="primary" :loading="saving" @click="handleSave">
保存配置
</Button>
</Space>
</FormItem>
</Form>
</Card>
</div>
</Page>
</template>

View File

@@ -68,13 +68,17 @@ export default defineConfig(async () => {
'/api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
target: 'http://127.0.0.1:6039',
// Gateway 地址 - 使用局域网地址或环境变量
target: process.env.VITE_API_URL || 'http://192.168.120.60:8080',
ws: true,
},
'/resource': {
changeOrigin: true,
target: 'http://127.0.0.1:6039',
target: process.env.VITE_API_URL || 'http://192.168.120.60:8080',
},
'/auth': {
changeOrigin: true,
target: process.env.VITE_API_URL || 'http://192.168.120.60:8080',
},
},
},