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,4 @@
# 开发环境配置
VITE_API_URL=http://localhost:8080
VITE_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
VITE_WEB_TITLE=Dealer Portal 经销商门户

View File

@@ -25,7 +25,7 @@ server {
# API 代理 - 代理所有后端 API 请求
# 匹配常见的后端 API 路径前缀
location ~ ^/(system|chat|auth|resource|agent|knowledge|workflow)/ {
location ~ ^/(system|chat|auth|resource|agent|knowledge|workflow|ai|erp)/ {
proxy_pass ${UPSTREAM_URL};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -1,4 +1,5 @@
export * from './auth';
export * from './chat';
export * from './erp';
export * from './model';
export * from './session';

View File

@@ -28,22 +28,10 @@ const appList = ref([
route: '/chat',
},
{
id: 'ai-image',
name: 'AI 画图',
icon: 'Picture',
route: '/ai-image',
},
{
id: 'ai-video',
name: 'AI 视频',
icon: 'VideoCamera',
route: '/ai-video',
},
{
id: 'ai-ppt',
name: 'AI PPT',
icon: 'Document',
route: '/ai-ppt',
id: 'dealer',
name: '经销商管理',
icon: 'Shop',
route: '/dealer',
},
]);
@@ -54,8 +42,7 @@ const activeFooterBtn = ref<'agent' | 'knowledge' | null>(null);
// 切换应用
function handleAppClick(app: typeof appList.value[0]) {
activeApp.value = app.id;
// 这里可以添加路由跳转逻辑
// router.push(app.route);
router.push(app.route);
}
// 智能体中心
@@ -73,8 +60,12 @@ function handleKnowledgeBase() {
}
onMounted(async () => {
// 默认选中 AI 对话应用
activeApp.value = 'ai-chat';
// 根据当前路由设置激活的应用
if (route.path.startsWith('/dealer')) {
activeApp.value = 'dealer';
} else {
activeApp.value = 'ai-chat';
}
// 获取会话列表
console.log('[Aside.onMounted] 开始获取会话列表');
@@ -91,6 +82,18 @@ onMounted(async () => {
}
});
// 监听路由变化,更新激活的应用
watch(
() => route.path,
(path) => {
if (path.startsWith('/dealer')) {
activeApp.value = 'dealer';
} else {
activeApp.value = 'ai-chat';
}
}
);
watch(
() => sessionStore.currentSession,
(newValue) => {

View File

@@ -0,0 +1,438 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getSalesAreas, getCustomerList, type CustomerVO } from '@/api/erp';
import { ElMessage } from 'element-plus';
// 选中的销区
const selectedArea = ref<string>('');
const salesAreas = ref<CustomerVO[]>([]);
const loadingAreas = ref(false);
// 经销商列表
const dealers = ref<CustomerVO[]>([]);
const loadingDealers = ref(false);
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(0);
// 加载销区列表
async function loadSalesAreas() {
loadingAreas.value = true;
try {
const res = await getSalesAreas();
// API 返回包装格式:{ code, msg, data }
salesAreas.value = res?.data || [];
} catch (error: any) {
ElMessage.error(error?.message || '加载销区列表失败');
} finally {
loadingAreas.value = false;
}
}
// 加载经销商列表
async function loadDealers() {
if (!selectedArea.value) {
dealers.value = [];
return;
}
loadingDealers.value = true;
try {
const res = await getCustomerList({
pageNum: currentPage.value,
pageSize: pageSize.value,
salesAreaCode: selectedArea.value,
});
dealers.value = res.rows || [];
total.value = res.total || 0;
} catch (error: any) {
ElMessage.error(error?.message || '加载经销商列表失败');
} finally {
loadingDealers.value = false;
}
}
// 选择销区
function handleAreaChange() {
currentPage.value = 1;
loadDealers();
}
// 分页
function handlePageChange(page: number) {
currentPage.value = page;
loadDealers();
}
// Logo 渐变色
const logoGradients = [
'linear-gradient(135deg, #1d5af3, #3378fc)',
'linear-gradient(135deg, #8b5cf6, #a78bfa)',
'linear-gradient(135deg, #16a34a, #22c55e)',
'linear-gradient(135deg, #d97706, #f59e0b)',
'linear-gradient(135deg, #dc2626, #ef4444)',
'linear-gradient(135deg, #0ea5e9, #38bdf8)',
'linear-gradient(135deg, #e11d48, #f43f5e)',
'linear-gradient(135deg, #64748b, #94a3b8)',
];
function getLogoBg(item: CustomerVO) {
const idx = item.customerCode.charCodeAt(item.customerCode.length - 1) % logoGradients.length;
return logoGradients[idx];
}
// 状态标签
function getStatusInfo(item: CustomerVO) {
if (item.isStop === 1) {
return { type: 'danger' as const, text: '已停用' };
}
return { type: 'success' as const, text: '合作中' };
}
// 手机号脱敏
function maskPhone(phone: string) {
if (!phone || phone.length < 7) return phone || '--';
return phone.slice(0, 3) + '****' + phone.slice(-4);
}
onMounted(() => {
loadSalesAreas();
});
</script>
<template>
<div class="dealer-page">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">经销商管理</h1>
<p class="page-desc">选择销区查看经销商信息</p>
</div>
<!-- Sales Area Selection -->
<div class="area-selector">
<div class="selector-label">选择销区</div>
<el-select
v-model="selectedArea"
placeholder="请选择销区"
clearable
filterable
style="width: 300px"
:loading="loadingAreas"
@change="handleAreaChange"
>
<el-option
v-for="area in salesAreas"
:key="area.salesAreaCode"
:label="`${area.salesAreaName} (${area.salesAreaCode})`"
:value="area.salesAreaCode"
/>
</el-select>
<div v-if="selectedArea" class="area-info">
<el-tag type="success" effect="plain" round>
已选销区{{ salesAreas.find(a => a.salesAreaCode === selectedArea)?.salesAreaName }}
</el-tag>
</div>
</div>
<!-- Dealer Cards -->
<div v-if="selectedArea" class="dealer-section">
<div class="section-header">
<h3 class="section-title">经销商列表</h3>
<span class="section-count"> {{ total }} </span>
</div>
<div class="dealer-grid" v-loading="loadingDealers">
<div
v-for="dealer in dealers"
:key="dealer.customerCode"
class="dealer-card"
>
<div class="card-header">
<div class="dealer-logo" :style="{ background: getLogoBg(dealer) }">
{{ dealer.customerName.charAt(0) }}
</div>
<div class="dealer-identity">
<span class="dealer-name">{{ dealer.customerName }}</span>
<span class="dealer-code">{{ dealer.customerCode }}</span>
</div>
<el-tag :type="getStatusInfo(dealer).type" size="small" effect="light" round>
{{ getStatusInfo(dealer).text }}
</el-tag>
</div>
<div class="card-body">
<div class="detail-row">
<span class="detail-label">销区</span>
<span class="detail-value">{{ dealer.salesAreaName || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">品牌</span>
<span class="detail-value">{{ dealer.brandName || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">联系人</span>
<span class="detail-value">{{ dealer.contactName || '--' }} · {{ maskPhone(dealer.phone) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">地址</span>
<span class="detail-value">{{ [dealer.province, dealer.city].filter(Boolean).join(' ') || '--' }}</span>
</div>
</div>
<div class="card-footer">
<div class="dealer-kpi">
<div class="kpi-item">
<span class="kpi-value">{{ dealer.sdOrgName || '--' }}</span>
<span class="kpi-label">经销组织</span>
</div>
<div class="kpi-item">
<span class="kpi-value">{{ dealer.pricePlanName || '--' }}</span>
<span class="kpi-label">价格方案</span>
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!loadingDealers && dealers.length === 0" class="empty-state">
<el-empty description="该销区暂无经销商数据" />
</div>
<!-- Pagination -->
<div class="pagination-bar" v-if="total > pageSize">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- Empty area hint -->
<div v-if="!selectedArea" class="empty-area-hint">
<el-empty description="请先选择销区查看经销商列表" />
</div>
</div>
</template>
<style scoped lang="scss">
.dealer-page {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px;
}
.page-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}
.area-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.selector-label {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.area-info {
margin-left: auto;
}
.dealer-section {
margin-top: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.section-count {
font-size: 13px;
color: var(--color-text-secondary);
}
.dealer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1200px) {
.dealer-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.dealer-grid {
grid-template-columns: 1fr;
}
}
.dealer-card {
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid rgba(0, 0, 0, 0.04);
transition: all 0.25s ease;
overflow: hidden;
&:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px 14px;
}
.dealer-logo {
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.dealer-identity {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.dealer-name {
font-size: 15px;
font-weight: 650;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dealer-code {
font-family: 'SF Mono', Menlo, monospace;
font-size: 11.5px;
color: var(--color-text-secondary);
margin-top: 1px;
}
.card-body {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.detail-value {
font-size: 13px;
color: var(--color-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
text-align: right;
}
.card-footer {
margin-top: 14px;
padding: 14px 20px;
border-top: 1px solid #f5f3f0;
}
.dealer-kpi {
display: flex;
justify-content: space-between;
}
.kpi-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.kpi-value {
font-size: 14px;
font-weight: 700;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
text-align: center;
}
.kpi-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.empty-state {
padding: 40px 0;
}
.pagination-bar {
display: flex;
justify-content: center;
margin-top: 20px;
}
.empty-area-hint {
padding: 60px 0;
}
</style>

View File

@@ -31,6 +31,16 @@ export const layoutRouter: RouteRecordRaw[] = [
isDefaultChat: false,
},
},
{
path: '/dealer',
name: 'dealer',
component: () => import('@/pages/dealer/index.vue'),
meta: {
title: '经销商管理',
icon: 'Shop',
isKeepAlive: '1',
},
},
],
},
];

33
hzhub-portal-dealer/start.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# hzhub-portal-dealer 启动脚本
cd "$(dirname "$0")"
PID_FILE=".pid"
LOG_FILE="logs/dev.log"
mkdir -p logs
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p "$PID" > /dev/null 2>&1; then
echo "Service already running (PID: $PID)"
exit 1
else
rm -f "$PID_FILE"
fi
fi
echo "Starting hzhub-portal-dealer..."
nohup pnpm dev > "$LOG_FILE" 2>&1 &
PID=$!
sleep 2
if ps -p "$PID" > /dev/null 2>&1; then
echo "$PID" > "$PID_FILE"
echo "hzhub-portal-dealer started (PID: $PID)"
echo "Port: http://localhost:5138"
else
echo "Failed to start. Check logs: $LOG_FILE"
exit 1
fi

34
hzhub-portal-dealer/stop.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# hzhub-portal-dealer 停止脚本
cd "$(dirname "$0")"
PID_FILE=".pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p "$PID" > /dev/null 2>&1; then
echo "Stopping hzhub-portal-dealer (PID: $PID)..."
kill "$PID" 2>/dev/null
sleep 2
if ps -p "$PID" > /dev/null 2>&1; then
echo "Force killing..."
kill -9 "$PID" 2>/dev/null
fi
echo "hzhub-portal-dealer stopped."
fi
rm -f "$PID_FILE"
fi
# Fallback: kill by port
if ss -tlnp 2>/dev/null | grep -q ':5138 '; then
echo "Port 5138 still in use, killing by port..."
fuser -k 5138/tcp 2>/dev/null
sleep 1
fi
if ! ss -tlnp 2>/dev/null | grep -q ':5138 '; then
echo "hzhub-portal-dealer stopped."
else
echo "hzhub-portal-dealer may still be running on port 5138."
fi

View File

@@ -23,7 +23,11 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElMain: typeof import('element-plus/es')['ElMain']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
@@ -38,4 +42,7 @@ declare module 'vue' {
VerificationCode: typeof import('./../src/components/LoginDialog/components/FormLogin/VerificationCode.vue')['default']
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -1,12 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_WEB_TITLE: string;
readonly VITE_WEB_TITLE_EN: string;
readonly VITE_WEB_ENV: string;
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_CLIENT_ID: string;
readonly VITE_WEB_TITLE: string;
}
declare interface ImportMeta {

View File

@@ -29,6 +29,17 @@ export default defineConfig((cnf) => {
headers: {
'Cache-Control': 'no-store',
},
proxy: {
'/api': {
changeOrigin: true,
target: 'http://127.0.0.1:8080',
ws: true,
},
'/erp': {
changeOrigin: true,
target: 'http://127.0.0.1:8080',
},
},
},
};
});