feat: 完善员工门户功能及ERP集成

主要修改:
- 完善员工门户CRM模块(经销商、线索管理)
- 添加ERP客户选择器集成
- 优化登录认证和租户选择
- 添加超时配置、企业微信集成等文档
- 更新docker-compose配置
- 将.pid临时文件加入gitignore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-22 09:46:54 +00:00
parent 5cb9e367df
commit 226f119607
65 changed files with 2988 additions and 831 deletions

View File

@@ -1 +1 @@
3357568
3183821

View File

@@ -1 +1 @@
3999223
3182697

View File

@@ -20,7 +20,8 @@ public class UserNameTranslationImpl implements TranslationInterface<String> {
@Override
public String translation(Object key, String other) {
if (key instanceof Long id) {
return userService.selectUserNameById(id);
// 返回用户昵称而不是登录账号
return userService.selectNicknameById(id);
}
return null;
}

View File

@@ -1 +1 @@
3422048
3183908

View File

@@ -22,7 +22,7 @@ services:
container_name: hzhub-ai-mysql
restart: always
ports:
- "23306:3306"
- '23306:3306'
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hzhub-ai-agent
@@ -30,7 +30,7 @@ services:
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"]
test: [CMD, mysqladmin, ping, -h, localhost, -u, root, -proot]
interval: 15s
timeout: 10s
retries: 10
@@ -44,12 +44,12 @@ services:
container_name: hzhub-ai-redis
restart: always
ports:
- "26379:6379"
- '26379:6379'
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [CMD, redis-cli, ping]
interval: 10s
timeout: 5s
retries: 5
@@ -62,7 +62,7 @@ services:
container_name: hzhub-ai-weaviate
restart: always
ports:
- "28080:8080"
- '28080:8080'
environment:
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: true
@@ -81,8 +81,8 @@ services:
container_name: hzhub-ai-minio
restart: always
ports:
- "29000:9000"
- "29090:9090"
- '29000:9000'
- '29090:9090'
environment:
MINIO_ROOT_USER: ruoyi
MINIO_ROOT_PASSWORD: ruoyi123
@@ -98,7 +98,7 @@ services:
container_name: hzhub-ai-backend
restart: always
ports:
- "26039:6039"
- '26039:6039'
environment:
TZ: Asia/Shanghai
# MySQL 配置
@@ -132,7 +132,7 @@ services:
container_name: hzhub-ai-admin
restart: always
ports:
- "25666:5666"
- '25666:5666'
environment:
# 后端 API 地址 - 运行时动态配置(无需重新构建镜像)
# 在完整 docker-compose 中应设置为: http://backend:6039
@@ -158,7 +158,7 @@ services:
container_name: hzhub-ai-web
restart: always
ports:
- "25137:5137"
- '25137:5137'
environment:
UPSTREAM_URL: http://backend:6039
depends_on:

View File

@@ -9,7 +9,7 @@ services:
container_name: hzhub-ai-web
restart: always
ports:
- "5137:5137"
- '5137:5137'
environment:
# 后端 API 地址 - 运行时通过 nginx 代理
# 在完整 docker-compose 中应设置为: http://backend:6039

View File

@@ -18,8 +18,8 @@
**关键代码**:
```typescript
import { useUserStore } from '@/stores';
import { computed } from 'vue';
import { useUserStore } from '@/stores';
const userStore = useUserStore();
@@ -49,8 +49,8 @@ const companyName = computed(() => {
**关键代码**:
```typescript
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
@@ -97,7 +97,7 @@ const tenantNameMap: Record<string, string> = {
'000002': '汇亚公司',
'000003': '恒福公司',
'000004': '玛缇公司',
'000005': '新公司名称', // 添加新公司
'000005': '新公司名称', // 添加新公司
};
```
@@ -109,7 +109,7 @@ const tenantNameMap: Record<string, string> = {
```typescript
export interface LoginUser {
// ... 现有字段
companyName?: string; // 新增公司名称字段
companyName?: string; // 新增公司名称字段
}
```

View File

@@ -97,28 +97,28 @@ src/
### 获取用户信息
```typescript
GET /system/user/profile
Response: UserProfile
GET / system / user / profile;
Response: UserProfile;
```
### 更新用户信息
```typescript
PUT /system/user/profile
PUT / system / user / profile;
Request: {
userId: number
nickName: string
email: string
phonenumber: string
sex: string
userId: number;
nickName: string;
email: string;
phonenumber: string;
sex: string;
}
```
### 修改密码
```typescript
PUT /system/user/profile/updatePwd
PUT / system / user / profile / updatePwd;
Request: {
oldPassword: string
newPassword: string
oldPassword: string;
newPassword: string;
}
```

View File

@@ -123,14 +123,14 @@ export function useTabbar() {
}, { immediate: true });
return {
currentActive, // 当前激活的标签页
currentTabs, // 标签页列表
handleClick, // 点击标签页
handleClose, // 关闭标签页
handleUnpin, // 固定/取消固定
closeOtherTabs, // 关闭其他标签页
closeAllTabs, // 关闭所有标签页
closeRightTabs, // 关闭右侧标签页
currentActive, // 当前激活的标签页
currentTabs, // 标签页列表
handleClick, // 点击标签页
handleClose, // 关闭标签页
handleUnpin, // 固定/取消固定
closeOtherTabs, // 关闭其他标签页
closeAllTabs, // 关闭所有标签页
closeRightTabs, // 关闭右侧标签页
};
}
```
@@ -149,25 +149,26 @@ const tabs = computed(() => tabbarStore.tabs);
const isFullscreen = ref(false);
// 点击标签页
const handleTabClick = (tab: TabItem) => {
function handleTabClick(tab: TabItem) {
tabbarStore.setActiveTab(tab.path);
router.push(tab.path);
};
}
// 关闭标签页
const handleTabClose = (path: string) => {
function handleTabClose(path: string) {
tabbarStore.closeTab(path);
};
}
// 切换全屏
const toggleFullscreen = () => {
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value;
if (isFullscreen.value) {
document.documentElement.requestFullscreen();
} else {
}
else {
document.exitFullscreen();
}
};
}
</script>
```
@@ -175,8 +176,8 @@ const toggleFullscreen = () => {
```vue
<script setup lang="ts">
import TabsView from '@/layouts/components/TabsView/index.vue';
import { useTabbar } from '@/hooks/useTabbar';
import TabsView from '@/layouts/components/TabsView/index.vue';
const { handleClose, handleUnpin } = useTabbar();
</script>

View File

@@ -1,5 +1,5 @@
import type { EmailCodeDTO, LoginDTO, LoginVO, RegisterDTO, TenantResp } from './types';
import { post, get } from '@/utils/request';
import { get, post } from '@/utils/request';
export const login = (data: LoginDTO) => post<LoginVO>('/auth/login', data).json();

View File

@@ -9,9 +9,14 @@ import type {
CrmLeadBo,
CrmLeadFollowBo,
CrmLeadFollowVo,
CrmLeadStatsVo,
CrmLeadVo,
CrmOpportunityBo,
CrmOpportunityVo,
CrmSyncAlertVo,
DealerQueryParams,
ErpCustomerSelectVo,
InstantSyncResult,
LeadAssignRequest,
LeadConvertRequest,
LeadQueryParams,
@@ -26,9 +31,14 @@ export type {
CrmLeadBo,
CrmLeadFollowBo,
CrmLeadFollowVo,
CrmLeadStatsVo,
CrmLeadVo,
CrmOpportunityBo,
CrmOpportunityVo,
CrmSyncAlertVo,
DealerQueryParams,
ErpCustomerSelectVo,
InstantSyncResult,
LeadAssignRequest,
LeadConvertRequest,
LeadQueryParams,
@@ -45,11 +55,18 @@ export function getLeadList(params: LeadQueryParams): Promise<TableDataInfo<CrmL
return request.get('/crm/lead/list', params).json();
}
/**
* 获取线索统计数据
*/
export function getLeadStats(): Promise<R<CrmLeadStatsVo>> {
return request.get('/crm/lead/stats').json();
}
/**
* 获取线索详情
*/
export function getLeadDetail(leadId: number): Promise<R<CrmLeadVo>> {
return request.get(`/crm/lead/${leadId}`).json();
return request.get(`/crm/lead/detail/${leadId}`).json();
}
/**
@@ -101,6 +118,20 @@ export function convertLeadToDealer(data: LeadConvertRequest): Promise<R<void>>
return request.post('/crm/lead/convert', data).json();
}
/**
* 作废线索
*/
export function invalidateLead(leadId: number): Promise<R<void>> {
return request.put(`/crm/lead/invalidate/${leadId}`).json();
}
/**
* 恢复线索
*/
export function restoreLead(leadId: number): Promise<R<void>> {
return request.put(`/crm/lead/restore/${leadId}`).json();
}
/**
* ========================================
* CRM 商机管理模块 API 调用
@@ -155,3 +186,58 @@ export function deleteOpportunity(opportunityIds: string): Promise<R<void>> {
export function getDealerSelectList(keyword?: string): Promise<R<CrmDealerVo[]>> {
return request.get('/crm/dealer/portal/select', { keyword }).json();
}
/**
* ========================================
* CRM 经销商管理扩展 API
* ========================================
*/
/**
* 获取CRM经销商分页列表
*/
export function getDealerList(params: DealerQueryParams): Promise<TableDataInfo<CrmDealerVo>> {
return request.get('/crm/dealer/portal/list', params).json();
}
/**
* 获取经销商详情(含同步状态)
*/
export function getDealerDetail(dealerId: number): Promise<R<CrmDealerVo>> {
return request.get(`/crm/dealer/portal/detail/${dealerId}`).json();
}
/**
* ERP客户选择器数据用于线索转化/经销商创建)
*/
export function getErpCustomerSelect(keyword?: string): Promise<R<ErpCustomerSelectVo[]>> {
return request.get('/crm/dealer/portal/erp-select', { keyword }).json();
}
/**
* 校验ERP客户编码是否存在
*/
export function validateCustomerCode(customerCode: string): Promise<R<boolean>> {
return request.get('/crm/dealer/portal/validate-code', { customerCode }).json();
}
/**
* 手动同步经销商
*/
export function manualSyncDealer(dealerId: number): Promise<R<InstantSyncResult>> {
return request.post(`/crm/dealer/portal/sync/${dealerId}`).json();
}
/**
* 获取经销商的待处理预警列表
*/
export function getDealerAlerts(dealerId: number): Promise<R<CrmSyncAlertVo[]>> {
return request.get(`/crm/dealer/portal/alerts/${dealerId}`).json();
}
/**
* 处理预警
*/
export function resolveAlert(alertId: number, action: string, note?: string): Promise<R<void>> {
return request.put(`/crm/dealer/portal/alerts/${alertId}`, null, { params: { action, note } }).json();
}

View File

@@ -85,6 +85,22 @@ export interface LeadQueryParams {
pageSize: number;
}
/**
* 线索统计视图对象
*/
export interface CrmLeadStatsVo {
totalCount: number; // 线索总数
highIntentCount: number; // 高意向线索数量
monthlyNewCount: number; // 本月新增线索数量
lastMonthNewCount: number; // 上月新增线索数量
convertedCount: number; // 已转化线索数量
conversionRate: number; // 转化率(百分比)
monthlyConvertedCount: number; // 本月转化数量
lastMonthConvertedCount: number; // 上月转化数量
monthlyHighIntentCount: number; // 本月高意向数量
lastMonthHighIntentCount: number; // 上月高意向数量
}
/**
* 线索跟进记录视图对象
*/
@@ -254,6 +270,7 @@ export interface CrmDealerVo {
sourceLeadId?: number; // 来源线索ID
status?: string; // 状态
statusName?: string; // 状态名称(翻译)
hasPendingAlerts?: boolean; // 是否有待处理预警(扩展字段)
createBy: number;
createByName?: string;
createTime: string;
@@ -261,3 +278,64 @@ export interface CrmDealerVo {
updateByName?: string;
updateTime?: string;
}
/**
* 经销商查询参数
*/
export interface DealerQueryParams {
dealerName?: string;
dealerCode?: string;
customerCode?: string;
level?: string;
lifecycle?: string;
ownerUserId?: number;
pageNum: number;
pageSize: number;
}
/**
* ERP客户选择器数据
*/
export interface ErpCustomerSelectVo {
customerCode: string;
customerName: string;
companyCode?: string;
companyName?: string;
contactName?: string;
phone?: string;
province?: string;
city?: string;
isStop?: number;
}
/**
* 同步预警视图对象
*/
export interface CrmSyncAlertVo {
id: number;
tenantId: string;
syncLogId?: number;
dealerId: number;
customerCode: string;
alertType: string;
crmValue?: string;
erpValue?: string;
alertMessage?: string;
status: string;
resolvedBy?: number;
resolvedTime?: string;
resolvedNote?: string;
createTime: string;
dealerName?: string;
}
/**
* 即时同步结果
*/
export interface InstantSyncResult {
success: boolean;
message: string;
updated: boolean;
alerts?: CrmSyncAlertVo[];
erpInfo?: Record<string, any>;
}

View File

@@ -63,7 +63,7 @@ export function getCustomerList(params: {
}) {
return request.get<{ code: number; msg: string; data: { rows: CustomerVO[]; total: number; code: number; msg: string } }>(
'/erp/dynamic/v1/customer/list',
params
params,
).json();
}

View File

@@ -1,4 +1,4 @@
import type { UserProfile, UpdatePasswordParam } from './types';
import type { UpdatePasswordParam, UserProfile } from './types';
import { get, put, request } from '@/utils/request';

View File

@@ -1,20 +1,20 @@
import { get, post } from '@/utils/request';
import type {
WecomApprovalVo,
WecomApprovalDetail,
ApprovalStats,
WecomTemplate,
ApprovalListParams,
ApprovalStats,
SubmitApprovalBo,
WecomApprovalDetail,
WecomApprovalVo,
WecomTemplate,
} from './types';
import { get, post } from '@/utils/request';
export type {
WecomApprovalVo,
WecomApprovalDetail,
ApprovalStats,
WecomTemplate,
ApprovalListParams,
ApprovalStats,
SubmitApprovalBo,
WecomApprovalDetail,
WecomApprovalVo,
WecomTemplate,
};
/** 分页查询审批列表 */

View File

@@ -2,13 +2,13 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from 'element-plus';
import type { LoginDTO, TenantResp } from '@/api/auth/types';
import { reactive, ref, onMounted, watch } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { login, tenantList } from '@/api';
import { useLoginTenantId } from '@/hooks/useLoginTenantId';
import { useUserStore } from '@/stores';
import { useLoginFormStore } from '@/stores/modules/loginForm';
import { useSessionStore } from '@/stores/modules/session';
import { useLoginTenantId } from '@/hooks/useLoginTenantId';
// 定义 emits
const emit = defineEmits<{
@@ -35,7 +35,7 @@ const formModel = reactive<LoginDTO>({
password: '',
clientId: import.meta.env.VITE_CLIENT_ID,
grantType: 'password',
tenantId: loginTenantId.value, // 使用全局状态
tenantId: loginTenantId.value, // 使用全局状态
uuid: 'a5705def96be468f80e4b8bde3127c31',
});
@@ -68,13 +68,15 @@ async function loadTenant() {
console.log('全局租户ID为默认值设置第一个租户:', firstTenantId);
loginTenantId.value = firstTenantId;
formModel.tenantId = firstTenantId;
} else {
}
else {
// 如果全局状态已有值,同步到表单
console.log('使用全局租户ID:', loginTenantId.value);
formModel.tenantId = loginTenantId.value;
}
console.log('最终表单租户ID:', formModel.tenantId);
} catch (error) {
}
catch (error) {
console.error('加载租户列表失败:', error);
}
}
@@ -105,7 +107,8 @@ async function handleSubmit() {
// 如果返回了用户信息,保存用户信息
if (res.data.userInfo) {
userStore.setUserInfo(res.data.userInfo);
} else {
}
else {
// 如果登录响应没有返回用户信息,创建默认用户信息
userStore.setUserInfo({
userId: 1,
@@ -129,11 +132,13 @@ async function handleSubmit() {
const redirect = router.currentRoute.value.query.redirect as string;
router.replace(redirect || '/');
}
} catch (error: any) {
}
catch (error: any) {
// 错误已经在 request.ts 的拦截器中处理并显示了
// 这里只需要记录日志,不需要再次显示错误信息
console.error('登录失败:', error);
} finally {
}
finally {
loading.value = false;
}
}

View File

@@ -1,11 +1,11 @@
export type LayoutType = 'vertical';
// 仿豆包折叠逻辑
export type CollapseType =
| 'alwaysCollapsed' // 始终折叠
| 'followSystem' // 跟随系统视口宽度
| 'alwaysExpanded' // 始终打开
| 'narrowExpandWideCollapse'; // 系统视口 宽小则张,宽大则收
export type CollapseType
= | 'alwaysCollapsed' // 始终折叠
| 'followSystem' // 跟随系统视口宽度
| 'alwaysExpanded' // 始终打开
| 'narrowExpandWideCollapse'; // 系统视口 宽小则张,宽大则收
export interface DesignConfigState {
// 系统主题

View File

@@ -1,8 +1,8 @@
import { onMounted, onUnmounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
import { INACTIVITY_TIMEOUT, INACTIVITY_WARNING } from '@/config';
import { useUserStore } from '@/stores';
import { useLockScreenStore } from '@/stores/modules/lockScreen';
import { INACTIVITY_TIMEOUT, INACTIVITY_WARNING } from '@/config';
/**
* 空闲超时自动锁屏

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue';
import { createGlobalState } from '@vueuse/core';
import { ref } from 'vue';
/**
* 全局租户ID状态

View File

@@ -1,12 +1,12 @@
<!-- 纵向布局作为基础布局 -->
<script setup lang="ts">
import { useInactivityTimer } from '@/hooks/useInactivityTimer';
import { useTabbar } from '@/hooks/useTabbar';
import Aside from '@/layouts/components/Aside/index.vue';
import Header from '@/layouts/components/Header/index.vue';
import TabsView from '@/layouts/components/TabsView/index.vue';
import LockScreen from '@/layouts/components/LockScreen.vue';
import { useTabbar } from '@/hooks/useTabbar';
import TabsView from '@/layouts/components/TabsView/index.vue';
import { useDesignStore } from '@/stores';
import { useInactivityTimer } from '@/hooks/useInactivityTimer';
import { useLockScreenStore } from '@/stores/modules/lockScreen';
const designStore = useDesignStore();

View File

@@ -99,7 +99,9 @@ function handleClick(item: any) {
<span class="user-name">{{ userStore.userInfo?.nickName || userStore.userInfo?.username || '未登录' }}</span>
<span class="user-role">{{ userStore.userInfo?.roles?.[0]?.roleName || userStore.userInfo?.deptName || '企业员工' }}</span>
</div>
<el-icon class="user-arrow"><ArrowDown /></el-icon>
<el-icon class="user-arrow">
<ArrowDown />
</el-icon>
</div>
</template>
@@ -110,7 +112,9 @@ function handleClick(item: any) {
class="popover-content-box-item flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
@click="handleClick(item)"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
<el-icon :size="16">
<component :is="item.icon" />
</el-icon>
<div class="popover-content-box-item-text font-size-14px text-overflow max-h-120px">
{{ item.title }}
</div>

View File

@@ -14,7 +14,9 @@ const searchKeyword = ref('');
<header class="top-header">
<div class="header-left">
<div class="breadcrumb-area">
<h2 class="page-title">{{ route.meta?.title || '工作台' }}</h2>
<h2 class="page-title">
{{ route.meta?.title || '工作台' }}
</h2>
<span v-if="route.meta?.subtitle" class="page-subtitle">{{ route.meta.subtitle }}</span>
</div>
</div>
@@ -29,7 +31,9 @@ const searchKeyword = ref('');
<el-tooltip content="消息通知" placement="bottom">
<el-badge :max="99" class="notification-btn">
<div class="icon-btn">
<el-icon :size="20"><Bell /></el-icon>
<el-icon :size="20">
<Bell />
</el-icon>
</div>
</el-badge>
</el-tooltip>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useLockScreenStore } from '@/stores/modules/lockScreen';
import { useUserStore } from '@/stores';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores';
import { useLockScreenStore } from '@/stores/modules/lockScreen';
const lockStore = useLockScreenStore();
const userStore = useUserStore();
@@ -27,12 +27,15 @@ async function handleUnlock() {
if (ok) {
ElMessage.success('解锁成功');
password.value = '';
} else {
}
else {
errorMsg.value = '密码错误,请重试';
}
} catch {
}
catch {
errorMsg.value = '网络异常,请重试';
} finally {
}
finally {
loading.value = false;
}
}
@@ -57,16 +60,16 @@ function onKeyup(e: KeyboardEvent) {
<div class="left-section">
<div class="logo-wrap">
<svg width="40" height="40" viewBox="0 0 28 28" fill="none">
<rect width="28" height="28" rx="8" fill="#1d5af3"/>
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
<rect width="28" height="28" rx="8" fill="#1d5af3" />
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5" />
</svg>
<span class="logo-text">HZHub 企业员工门户</span>
</div>
<div class="user-icon-area">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="45" r="28" fill="#1d5af3" opacity="0.9"/>
<ellipse cx="60" cy="95" rx="42" ry="30" fill="#1d5af3" opacity="0.9"/>
<circle cx="60" cy="45" r="28" fill="#1d5af3" opacity="0.9" />
<ellipse cx="60" cy="95" rx="42" ry="30" fill="#1d5af3" opacity="0.9" />
</svg>
</div>
</div>
@@ -74,15 +77,21 @@ function onKeyup(e: KeyboardEvent) {
<!-- 右侧解锁区域 -->
<div class="right-section">
<div class="lock-card">
<div class="lock-title">屏幕已锁定</div>
<div class="lock-user">{{ nickName }}</div>
<div class="lock-time" v-if="lockStore.lockTime">锁定于 {{ lockStore.lockTime }}</div>
<div class="lock-title">
屏幕已锁定
</div>
<div class="lock-user">
{{ nickName }}
</div>
<div v-if="lockStore.lockTime" class="lock-time">
锁定于 {{ lockStore.lockTime }}
</div>
<div class="lock-form">
<div class="input-wrapper">
<svg class="lock-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#a8a49c" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<input
v-model="password"
@@ -91,10 +100,12 @@ function onKeyup(e: KeyboardEvent) {
class="lock-input"
autofocus
@keyup.enter="handleUnlock"
/>
>
</div>
<div v-if="errorMsg" class="lock-error">{{ errorMsg }}</div>
<div v-if="errorMsg" class="lock-error">
{{ errorMsg }}
</div>
<button class="unlock-btn" :disabled="loading" @click="handleUnlock">
<span v-if="!loading">解锁</span>
@@ -104,7 +115,9 @@ function onKeyup(e: KeyboardEvent) {
<div class="lock-footer">
<span class="footer-text"></span>
<button class="logout-link" @click="handleLogout">退出登录</button>
<button class="logout-link" @click="handleLogout">
退出登录
</button>
</div>
</div>
</div>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { getApprovalList, getUserApprovalStats, syncCurrentUserApprovals, getTemplateList, getApprovalDetail, type WecomApprovalVo, type ApprovalStats, type WecomTemplate, type WecomApprovalDetail } from '@/api/wecom';
import type { ApprovalStats, WecomApprovalDetail, WecomApprovalVo, WecomTemplate } from '@/api/wecom';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { getApprovalDetail, getApprovalList, getTemplateList, getUserApprovalStats, syncCurrentUserApprovals } from '@/api/wecom';
// Tab
const activeTab = ref('all');
@@ -47,11 +48,18 @@ const statusOptions = [
];
const statusTagMap: Record<string, any> = {
1: 'warning', 2: 'success', 3: 'danger', 4: 'info',
1: 'warning',
2: 'success',
3: 'danger',
4: 'info',
};
const statusTextMap: Record<number, string> = {
1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤销', 6: '通过后撤销',
1: '审批中',
2: '已通过',
3: '已驳回',
4: '已撤销',
6: '通过后撤销',
};
async function loadStats() {
@@ -61,7 +69,8 @@ async function loadStats() {
// res 是 { code: 200, data: {...} } 格式,需要提取 data
approvalStats.value = (res?.data || res) as ApprovalStats;
console.log('统计数据:', approvalStats.value);
} catch (e) {
}
catch (e) {
console.error('加载统计数据失败:', e);
}
}
@@ -75,7 +84,8 @@ async function loadTemplates() {
// 过滤掉模板名称为空的模板
templates.value = templateList.filter(t => t.templateName && t.templateName.trim());
console.log('过滤后的模板:', templates.value);
} catch (e) {
}
catch (e) {
console.error('加载模板失败:', e);
}
}
@@ -97,10 +107,12 @@ async function loadData() {
approvals.value = data?.rows || [];
total.value = data?.total || 0;
console.log('审批列表:', approvals.value.length, '条');
} catch (error: any) {
}
catch (error: any) {
console.error('加载审批列表失败:', error);
ElMessage.error(error?.message || '加载审批列表失败');
} finally {
}
finally {
loading.value = false;
}
}
@@ -109,15 +121,18 @@ async function handleSync() {
try {
loading.value = true;
const result = await syncCurrentUserApprovals();
if (result instanceof Error) return;
if (result instanceof Error)
return;
// result 是 { code: 200, data: "同步完成..." } 格式
const message = (result?.data || result) as string;
ElMessage.success(message || '同步完成');
await loadData();
await loadStats();
} catch {
}
catch {
// 拦截器已显示错误
} finally {
}
finally {
loading.value = false;
}
}
@@ -187,63 +202,73 @@ async function handleView(row: WecomApprovalVo) {
}));
console.log('nodes 解析结果:', currentDetail.value.nodes);
}
} catch (e) {
}
catch (e) {
console.warn('nodesData 解析失败:', e);
}
}
console.log('最终审批详情:', currentDetail.value);
} else {
}
else {
console.warn('审批详情数据格式异常:', res);
}
} catch (e) {
}
catch (e) {
console.error('获取审批详情失败:', e);
ElMessage.error('获取审批详情失败');
detailVisible.value = false;
} finally {
}
finally {
detailLoading.value = false;
}
}
function getStatusText(status: number): string {
const statusMap: Record<number, string> = {
1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤销', 6: '通过后撤销',
1: '审批中',
2: '已通过',
3: '已驳回',
4: '已撤销',
6: '通过后撤销',
};
return statusMap[status] || '未知状态';
}
function getControlTypeText(control: string): string {
const controlMap: Record<string, string> = {
'Text': '文本',
'Textarea': '多行文本',
'Number': '数字',
'Money': '金额',
'Date': '日期',
'Selector': '选择器',
'Contact': '联系人',
'Location': '位置',
'RelatedApproval': '关联审批',
'File': '文件',
'Table': '表格',
'Attendance': '打卡',
'Vacation': '假期',
'Trip': '出差',
'Overtime': '加班',
'Work': '工作',
'Switch': '开关',
Text: '文本',
Textarea: '多行文本',
Number: '数字',
Money: '金额',
Date: '日期',
Selector: '选择器',
Contact: '联系人',
Location: '位置',
RelatedApproval: '关联审批',
File: '文件',
Table: '表格',
Attendance: '打卡',
Vacation: '假期',
Trip: '出差',
Overtime: '加班',
Work: '工作',
Switch: '开关',
};
return controlMap[control] || control;
}
// Format timestamp to readable date
function formatTime(ts: number): string {
if (!ts) return '--';
if (!ts)
return '--';
const d = new Date(ts * 1000);
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function formatAmount(n: number | null): string {
if (!n) return '—';
if (!n)
return '—';
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}
@@ -257,9 +282,7 @@ function extractValueByControl(control: string, value: any): string {
}
// 企业微信通用的空数组字段(需要过滤)
const emptyFields = ['docs', 'tips', 'files', 'classes', 'members', 'children',
'students', 'sum_field', 'stat_field', 'departments', 'wedrive_files',
'related_approval'];
const emptyFields = ['docs', 'tips', 'files', 'classes', 'members', 'children', 'students', 'sum_field', 'stat_field', 'departments', 'wedrive_files', 'related_approval'];
// 根据控件类型提取对应的value字段
switch (control) {
@@ -280,7 +303,8 @@ function extractValueByControl(control: string, value: any): string {
// 日期控件value.date时间戳或 value.date_range
if (value.date) {
return formatTime(value.date);
} else if (value.date_range?.begin && value.date_range?.end) {
}
else if (value.date_range?.begin && value.date_range?.end) {
return `${formatTime(value.date_range.begin)}${formatTime(value.date_range.end)}`;
}
return '--';
@@ -289,7 +313,8 @@ function extractValueByControl(control: string, value: any): string {
// 选择器控件value.selector.title单选或 value.selector.options多选
if (value.selector?.title) {
return value.selector.title;
} else if (value.selector?.options && Array.isArray(value.selector.options)) {
}
else if (value.selector?.options && Array.isArray(value.selector.options)) {
return value.selector.options.map(opt => opt.title || opt.key).join(', ');
}
return '--';
@@ -315,7 +340,7 @@ function extractValueByControl(control: string, value: any): string {
case 'Table':
// 表格控件value.children多行数据
if (value.children && Array.isArray(value.children) && value.children.length > 0) {
const rows = value.children.map(row => {
const rows = value.children.map((row) => {
// 每行可能包含多个单元格,提取有意义的数据
return Object.entries(row)
.filter(([k, v]) => !emptyFields.includes(k) && v !== null && v !== undefined)
@@ -354,9 +379,12 @@ function extractValueByControl(control: string, value: any): string {
// 假期控件value.vacation
if (value.vacation) {
const parts = [];
if (value.vacation.type) parts.push(`类型: ${value.vacation.type}`);
if (value.vacation.begin) parts.push(`开始: ${formatTime(value.vacation.begin)}`);
if (value.vacation.end) parts.push(`结束: ${value.vacation.end}`);
if (value.vacation.type)
parts.push(`类型: ${value.vacation.type}`);
if (value.vacation.begin)
parts.push(`开始: ${formatTime(value.vacation.begin)}`);
if (value.vacation.end)
parts.push(`结束: ${value.vacation.end}`);
return parts.length > 0 ? parts.join('\n') : '--';
}
return '--';
@@ -365,10 +393,14 @@ function extractValueByControl(control: string, value: any): string {
// 出差控件value.trip
if (value.trip) {
const parts = [];
if (value.trip.destination) parts.push(`目的地: ${value.trip.destination}`);
if (value.trip.begin) parts.push(`开始: ${formatTime(value.trip.begin)}`);
if (value.trip.end) parts.push(`结束: ${formatTime(value.trip.end)}`);
if (value.trip.duration) parts.push(`时长: ${value.trip.duration}`);
if (value.trip.destination)
parts.push(`目的地: ${value.trip.destination}`);
if (value.trip.begin)
parts.push(`开始: ${formatTime(value.trip.begin)}`);
if (value.trip.end)
parts.push(`结束: ${formatTime(value.trip.end)}`);
if (value.trip.duration)
parts.push(`时长: ${value.trip.duration}`);
return parts.length > 0 ? parts.join('\n') : '--';
}
return '--';
@@ -377,9 +409,12 @@ function extractValueByControl(control: string, value: any): string {
// 加班控件value.overtime
if (value.overtime) {
const parts = [];
if (value.overtime.begin) parts.push(`开始: ${formatTime(value.overtime.begin)}`);
if (value.overtime.end) parts.push(`结束: ${formatTime(value.overtime.end)}`);
if (value.overtime.duration) parts.push(`时长: ${value.overtime.duration}`);
if (value.overtime.begin)
parts.push(`开始: ${formatTime(value.overtime.begin)}`);
if (value.overtime.end)
parts.push(`结束: ${formatTime(value.overtime.end)}`);
if (value.overtime.duration)
parts.push(`时长: ${value.overtime.duration}`);
return parts.length > 0 ? parts.join('\n') : '--';
}
return '--';
@@ -408,14 +443,18 @@ function extractValueGeneric(value: any, emptyFields: string[]): string {
const meaningfulData: string[] = [];
for (const [key, val] of Object.entries(value)) {
if (emptyFields.includes(key)) continue;
if (!val || (Array.isArray(val) && val.length === 0)) continue;
if (emptyFields.includes(key))
continue;
if (!val || (Array.isArray(val) && val.length === 0))
continue;
if (typeof val === 'string' && val.trim()) {
meaningfulData.push(val.trim());
} else if (typeof val === 'number') {
}
else if (typeof val === 'number') {
meaningfulData.push(String(val));
} else if (typeof val === 'object' && !Array.isArray(val)) {
}
else if (typeof val === 'object' && !Array.isArray(val)) {
// 从对象中提取常见文本字段
const textFields = ['state', 'title', 'name', 'text', 'content', 'type', 'destination', 'duration'];
for (const field of textFields) {
@@ -530,15 +569,15 @@ onMounted(() => {
<!-- Stats -->
<div class="approval-stats">
<div class="a-stat" v-for="s in statCards" :key="s.label">
<div class="a-stat-dot" :style="{ background: s.color }"></div>
<div v-for="s in statCards" :key="s.label" class="a-stat">
<div class="a-stat-dot" :style="{ background: s.color }" />
<span class="a-stat-value">{{ s.value }}</span>
<span class="a-stat-label">{{ s.label }}</span>
</div>
</div>
<!-- Table -->
<div class="table-card" v-loading="loading">
<div v-loading="loading" class="table-card">
<el-table :data="approvals" stripe style="width: 100%" row-class-name="approval-row">
<el-table-column prop="spNo" label="审批单号" width="150">
<template #default="{ row }">
@@ -552,19 +591,25 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="spName" label="审批标题" min-width="200">
<template #default="{ row }">
<div class="cell-title">{{ row.spName || '--' }}</div>
<div class="cell-sub">{{ row.summaryInfo || '' }}</div>
<div class="cell-title">
{{ row.spName || '--' }}
</div>
<div class="cell-sub">
{{ row.summaryInfo || '' }}
</div>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="amount" v-if="row.amount">{{ formatAmount(row.amount) }}</span>
<span class="no-amount" v-else></span>
<span v-if="row.amount" class="amount">{{ formatAmount(row.amount) }}</span>
<span v-else class="no-amount"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagMap[row.spStatus] || 'info'" size="small" effect="light" round>{{ row.spStatusText || statusTextMap[row.spStatus] || '未知' }}</el-tag>
<el-tag :type="statusTagMap[row.spStatus] || 'info'" size="small" effect="light" round>
{{ row.spStatusText || statusTextMap[row.spStatus] || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
@@ -574,7 +619,9 @@ onMounted(() => {
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleView(row)">详情</el-button>
<el-button type="primary" link size="small" @click="handleView(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
@@ -601,7 +648,9 @@ onMounted(() => {
<div v-if="currentDetail">
<!-- 基本信息 -->
<div class="detail-section">
<h4 class="section-title">基本信息</h4>
<h4 class="section-title">
基本信息
</h4>
<div class="detail-row">
<span class="detail-label">审批单号:</span>
<span class="detail-value mono-id">{{ currentDetail.sp_no }}</span>
@@ -628,10 +677,14 @@ onMounted(() => {
<!-- 申请内容 -->
<div class="detail-section">
<h4 class="section-title">申请内容</h4>
<h4 class="section-title">
申请内容
</h4>
<div v-if="currentDetail.apply_data?.contents?.length">
<div v-for="(item, idx) in currentDetail.apply_data.contents" :key="idx" class="form-item">
<div class="form-label">{{ item.title?.[0]?.text || item.id }}</div>
<div class="form-label">
{{ item.title?.[0]?.text || item.id }}
</div>
<div class="form-value">
<!-- 根据企业微信控件规范显示内容 -->
<div style="white-space: pre-wrap">
@@ -648,13 +701,17 @@ onMounted(() => {
</div>
<div v-else style="color: #999; padding: 20px; text-align: center">
无申请内容数据
<div style="margin-top: 10px; font-size: 12px">apply_data: {{ JSON.stringify(currentDetail.apply_data, null, 2) }}</div>
<div style="margin-top: 10px; font-size: 12px">
apply_data: {{ JSON.stringify(currentDetail.apply_data, null, 2) }}
</div>
</div>
</div>
<!-- 审批流程 -->
<div class="detail-section" v-if="currentDetail.nodes?.length">
<h4 class="section-title">审批流程</h4>
<div v-if="currentDetail.nodes?.length" class="detail-section">
<h4 class="section-title">
审批流程
</h4>
<div v-for="(node, idx) in currentDetail.nodes" :key="idx" class="approval-node">
<div class="node-header">
<span class="node-title">{{ getNodeTitle(node.apv_rel) }}</span>
@@ -678,14 +735,18 @@ onMounted(() => {
</div>
<!-- 评论记录 -->
<div class="detail-section" v-if="currentDetail.comments?.length">
<h4 class="section-title">评论记录</h4>
<div v-if="currentDetail.comments?.length" class="detail-section">
<h4 class="section-title">
评论记录
</h4>
<div v-for="(comment, idx) in currentDetail.comments" :key="idx" class="comment-item">
<div class="comment-header">
<span class="commenter">{{ comment.commentUserInfo?.userid }}</span>
<span class="comment-time">{{ formatTime(comment.commentTime) }}</span>
</div>
<div class="comment-content">{{ comment.commentContent }}</div>
<div class="comment-content">
{{ comment.commentContent }}
</div>
</div>
</div>
</div>

View File

@@ -6,8 +6,12 @@
<template>
<div class="bi-container">
<div class="module-header">
<h1 class="module-title">数据分析</h1>
<p class="module-subtitle">企业经营数据可视化分析平台</p>
<h1 class="module-title">
数据分析
</h1>
<p class="module-subtitle">
企业经营数据可视化分析平台
</p>
</div>
<div class="module-content">
@@ -15,7 +19,9 @@
<el-icon class="placeholder-icon" color="#8b5cf6" :size="64">
<TrendCharts />
</el-icon>
<h2 class="placeholder-title">功能开发中敬请期待</h2>
<h2 class="placeholder-title">
功能开发中敬请期待
</h2>
<p class="placeholder-text">
BI数据分析模块将提供销售分析客户分析经销商分析
供应链分析等可视化报表支持自定义报表和数据钻取

View File

@@ -1,7 +1,7 @@
<!-- 默认消息列表页 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import type { AnyObject } from 'typescript-api-pro';
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { nextTick, onMounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x';
import { getKnowledgeList, getWorkflowList } from '@/api/chat';

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
<!-- ChatWidget AI对话组件 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { Sender } from 'vue-element-plus-x';
import { getKnowledgeList } from '@/api/chat';
import ModelSelect from '@/components/ModelSelect/index.vue';
@@ -34,7 +33,7 @@ const currentSessionId = computed({
if (session) {
sessionStore.setCurrentSession(session);
}
}
},
});
// 加载知识库列表
@@ -93,7 +92,9 @@ onMounted(() => {
<el-icon class="header-icon" color="#1d5af3">
<ChatLineRound />
</el-icon>
<h3 class="card-title">AI 对话助手</h3>
<h3 class="card-title">
AI 对话助手
</h3>
</div>
<el-button
class="toggle-btn"

View File

@@ -8,7 +8,7 @@ const pendingTasks = ref([
type: '合同审批',
status: 'pending',
createTime: '2026-04-01 10:30',
urgent: true
urgent: true,
},
{
id: 2,
@@ -16,7 +16,7 @@ const pendingTasks = ref([
type: '订单审批',
status: 'pending',
createTime: '2026-04-01 09:15',
urgent: false
urgent: false,
},
{
id: 3,
@@ -24,8 +24,8 @@ const pendingTasks = ref([
type: '退款审批',
status: 'pending',
createTime: '2026-03-31 16:20',
urgent: false
}
urgent: false,
},
]);
function handleTaskClick(task: any) {
@@ -37,7 +37,9 @@ function handleTaskClick(task: any) {
<template>
<div class="pending-tasks-card">
<div class="card-header">
<h3 class="card-title">待处理任务</h3>
<h3 class="card-title">
待处理任务
</h3>
<el-badge :value="pendingTasks.length" type="warning" />
</div>
@@ -51,7 +53,9 @@ function handleTaskClick(task: any) {
>
<div class="task-info">
<div class="task-title-row">
<el-tag v-if="task.urgent" type="danger" size="small" effect="plain">紧急</el-tag>
<el-tag v-if="task.urgent" type="danger" size="small" effect="plain">
紧急
</el-tag>
<span class="task-title">{{ task.title }}</span>
</div>
<div class="task-meta">

View File

@@ -9,7 +9,7 @@ const quickActions = ref([
handler: () => {
ElMessage.info('创建审批');
// TODO: Navigate to approval creation page
}
},
},
{
id: 'add-dealer',
@@ -19,7 +19,7 @@ const quickActions = ref([
handler: () => {
ElMessage.info('新增经销商');
// TODO: Navigate to dealer creation page
}
},
},
{
id: 'new-customer',
@@ -29,7 +29,7 @@ const quickActions = ref([
handler: () => {
ElMessage.info('新建客户');
// TODO: Navigate to CRM creation page
}
},
},
{
id: 'create-order',
@@ -39,7 +39,7 @@ const quickActions = ref([
handler: () => {
ElMessage.info('创建订单');
// TODO: Navigate to supply order creation page
}
},
},
{
id: 'agent-center',
@@ -49,7 +49,7 @@ const quickActions = ref([
handler: () => {
ElMessage.info('AI 智能体中心');
// TODO: Open AI agent center (moved from sidebar footer)
}
},
},
{
id: 'knowledge-base',
@@ -59,15 +59,17 @@ const quickActions = ref([
handler: () => {
ElMessage.info('知识库管理');
// TODO: Open knowledge base management (moved from sidebar footer)
}
}
},
},
]);
</script>
<template>
<div class="quick-actions-card">
<div class="card-header">
<h3 class="card-title">快捷操作</h3>
<h3 class="card-title">
快捷操作
</h3>
</div>
<div class="card-body">

View File

@@ -9,7 +9,7 @@ const recentActivities = ref([
user: '张三',
time: '10分钟前',
icon: 'DocumentChecked',
color: '#22c55e'
color: '#22c55e',
},
{
id: 2,
@@ -18,7 +18,7 @@ const recentActivities = ref([
user: '李四',
time: '1小时前',
icon: 'Shop',
color: '#1d5af3'
color: '#1d5af3',
},
{
id: 3,
@@ -27,7 +27,7 @@ const recentActivities = ref([
user: '赵六',
time: '2小时前',
icon: 'User',
color: '#8b5cf6'
color: '#8b5cf6',
},
{
id: 4,
@@ -36,15 +36,17 @@ const recentActivities = ref([
user: '孙七',
time: '3小时前',
icon: 'Truck',
color: '#f59e0b'
}
color: '#f59e0b',
},
]);
</script>
<template>
<div class="recent-activities-card">
<div class="card-header">
<h3 class="card-title">最近活动</h3>
<h3 class="card-title">
最近活动
</h3>
<el-button size="small" text>
查看全部
</el-button>
@@ -62,9 +64,13 @@ const recentActivities = ref([
</el-icon>
<div class="activity-content">
<div class="activity-title">{{ activity.title }}</div>
<div class="activity-title">
{{ activity.title }}
</div>
<div class="activity-meta">
<el-tag size="small" type="info" effect="plain">{{ activity.type }}</el-tag>
<el-tag size="small" type="info" effect="plain">
{{ activity.type }}
</el-tag>
<span class="activity-user">{{ activity.user }}</span>
<span class="activity-time">{{ activity.time }}</span>
</div>

View File

@@ -23,7 +23,9 @@ const props = defineProps<{
</div>
<div class="card-body">
<div class="card-value">{{ stat.value }}</div>
<div class="card-value">
{{ stat.value }}
</div>
<div v-if="stat.trend" class="card-trend" :class="{ 'trend-up': stat.trend.startsWith('+'), 'trend-down': stat.trend.startsWith('-') }">
{{ stat.trend }}
</div>

View File

@@ -1,18 +1,20 @@
<!-- Dashboard 工作台 -->
<script setup lang="ts">
import { useUserStore } from '@/stores';
import ChatWidget from './components/ChatWidget.vue';
import PendingTasks from './components/PendingTasks.vue';
import QuickActions from './components/QuickActions.vue';
import ChatWidget from './components/ChatWidget.vue';
import RecentActivities from './components/RecentActivities.vue';
import { useUserStore } from '@/stores';
const userStore = useUserStore();
// 根据当前时间段返回问候语
const greeting = computed(() => {
const hour = new Date().getHours();
if (hour < 12) return '早上好';
if (hour < 18) return '下午好';
if (hour < 12)
return '早上好';
if (hour < 18)
return '下午好';
return '晚上好';
});
@@ -38,7 +40,7 @@ const stats = ref([
{ label: '待审批', value: '12', icon: 'DocumentChecked', trend: '12.5%', trendUp: true, iconBg: '#eef5ff', iconColor: '#1d5af3' },
{ label: '本月新增经销商', value: '5', icon: 'Shop', trend: '8.2%', trendUp: true, iconBg: '#f0fdf4', iconColor: '#16a34a' },
{ label: '待跟进客户', value: '23', icon: 'User', trend: '3.1%', trendUp: false, iconBg: '#fef3c7', iconColor: '#d97706' },
{ label: '待发货订单', value: '8', icon: 'Truck', trend: '2项', trendUp: false, iconBg: '#fef2f2', iconColor: '#dc2626' }
{ label: '待发货订单', value: '8', icon: 'Truck', trend: '2项', trendUp: false, iconBg: '#fef2f2', iconColor: '#dc2626' },
]);
</script>
@@ -46,8 +48,12 @@ const stats = ref([
<div class="dashboard-container">
<!-- 头部 -->
<div class="dashboard-header">
<h1 class="dashboard-title">{{ greeting }}{{ nickName }}</h1>
<p class="dashboard-subtitle">{{ todayInfo }}你有<strong class="accent">{{ pendingCount }}</strong>项待处理事项</p>
<h1 class="dashboard-title">
{{ greeting }}{{ nickName }}
</h1>
<p class="dashboard-subtitle">
{{ todayInfo }}你有<strong class="accent">{{ pendingCount }}</strong>项待处理事项
</p>
</div>
<!-- 统计卡片网格 -->
@@ -58,14 +64,18 @@ const stats = ref([
class="stat-card"
>
<div class="stat-icon" :style="{ background: stat.iconBg }">
<el-icon :size="22" :style="{ color: stat.iconColor }"><component :is="stat.icon" /></el-icon>
<el-icon :size="22" :style="{ color: stat.iconColor }">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
<div class="stat-trend" :class="stat.trendUp ? 'up' : 'down'">
<el-icon :size="14"><component :is="stat.trendUp ? 'Top' : 'Bottom'" /></el-icon>
<el-icon :size="14">
<component :is="stat.trendUp ? 'Top' : 'Bottom'" />
</el-icon>
{{ stat.trend }}
</div>
</div>

View File

@@ -1,19 +1,32 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { getCustomerList, getSalesAreas, getBrands, type CustomerVO } from '@/api/erp';
import type { CustomerVO } from '@/api/erp';
import type { CrmDealerVo, CrmSyncAlertVo } from '@/api/crm';
import { ElMessage } from 'element-plus';
import { onMounted, ref } from 'vue';
import { getBrands, getCustomerList, getSalesAreas } from '@/api/erp';
import { getDealerList, manualSyncDealer, resolveAlert } from '@/api/crm';
// 数据源切换
const dataSource = ref<'erp' | 'crm'>('erp');
// 统计
const totalDealers = ref(0);
// 筛选
const filters = ref({
// ERP筛选
const erpFilters = ref({
salesAreaCode: '',
brand: '',
isStop: '' as '' | '0' | '1',
keyword: '',
});
// CRM筛选
const crmFilters = ref({
lifecycle: '',
level: '',
keyword: '',
});
// 下拉选项
const salesAreas = ref<CustomerVO[]>([]);
const brands = ref<CustomerVO[]>([]);
@@ -22,58 +35,130 @@ const brands = ref<CustomerVO[]>([]);
const currentPage = ref(1);
const pageSize = ref(12);
const total = ref(0);
const dealerList = ref<CustomerVO[]>([]);
// ERP客户列表
const erpDealerList = ref<CustomerVO[]>([]);
// CRM经销商列表
const crmDealerList = ref<CrmDealerVo[]>([]);
const loading = ref(false);
const syncLoading = ref(false);
async function loadDealerList() {
// 同步预警对话框
const showAlertsDialog = ref(false);
const currentAlerts = ref<CrmSyncAlertVo[]>([]);
const currentAlertDealer = ref<CrmDealerVo | null>(null);
// 切换数据源
async function handleDataSourceChange(val: string | number | boolean | undefined) {
const source = val as 'erp' | 'crm';
dataSource.value = source;
currentPage.value = 1;
if (source === 'erp') {
await loadErpDealerList();
} else {
await loadCrmDealerList();
}
}
// 加载ERP客户列表
async function loadErpDealerList() {
loading.value = true;
console.log('=== 开始加载经销商列表 ===');
console.log('当前页:', currentPage.value, '每页数量:', pageSize.value);
console.log('筛选条件:', filters.value);
try {
const res = await getCustomerList({
pageNum: currentPage.value,
pageSize: pageSize.value,
keyword: filters.value.keyword || undefined,
salesAreaCode: filters.value.salesAreaCode || undefined,
brand: filters.value.brand || undefined,
keyword: erpFilters.value.keyword || undefined,
salesAreaCode: erpFilters.value.salesAreaCode || undefined,
brand: erpFilters.value.brand || undefined,
});
console.log('API响应:', res);
// 处理嵌套的响应结构
const data = res.data || res; // 兼容两种响应格式
console.log('数据对象:', data);
console.log('数据行数:', data.rows?.length, '总数:', data.total);
const data = res.data || res;
let rows = data.rows || [];
// 状态筛选(后端不支持,前端过滤)
if (filters.value.isStop !== '') {
const stopFlag = Number(filters.value.isStop);
if (erpFilters.value.isStop !== '') {
const stopFlag = Number(erpFilters.value.isStop);
rows = rows.filter((d: CustomerVO) => d.isStop === stopFlag);
}
dealerList.value = rows;
erpDealerList.value = rows;
total.value = data.total || 0;
totalDealers.value = data.total || 0;
} catch (error: any) {
ElMessage.error(error?.message || '加载客户列表失败');
} finally {
loading.value = false;
}
}
// 加载CRM经销商列表
async function loadCrmDealerList() {
loading.value = true;
try {
const res = await getDealerList({
pageNum: currentPage.value,
pageSize: pageSize.value,
dealerName: crmFilters.value.keyword || undefined,
lifecycle: crmFilters.value.lifecycle || undefined,
level: crmFilters.value.level || undefined,
});
const data = res.rows ? res : { rows: [], total: 0 };
crmDealerList.value = data.rows || [];
total.value = data.total || 0;
totalDealers.value = data.total || 0;
console.log('最终显示经销商数量:', dealerList.value.length);
} catch (error: any) {
console.error('加载经销商列表失败:', error);
ElMessage.error(error?.message || '加载经销商列表失败');
} finally {
loading.value = false;
}
}
// 手动同步经销商
async function handleSync(dealer: CrmDealerVo) {
syncLoading.value = true;
try {
const res = await manualSyncDealer(dealer.dealerId);
const result = res.data;
if (result?.success) {
if (result.updated) {
ElMessage.success('同步成功已更新ERP最新信息');
} else {
ElMessage.info('数据已是最新,无需更新');
}
if (result.alerts && result.alerts.length > 0) {
showSyncAlertsDialog(dealer, result.alerts);
}
await loadCrmDealerList();
} else {
ElMessage.error(result?.message || '同步失败');
}
} catch {
ElMessage.error('同步失败');
} finally {
syncLoading.value = false;
}
}
// 显示同步预警对话框
function showSyncAlertsDialog(dealer: CrmDealerVo, alerts: CrmSyncAlertVo[]) {
currentAlertDealer.value = dealer;
currentAlerts.value = alerts;
showAlertsDialog.value = true;
}
// 处理预警
async function handleResolveAlert(alertId: number, action: 'acknowledge' | 'ignore') {
try {
await resolveAlert(alertId, action);
ElMessage.success('预警已处理');
showAlertsDialog.value = false;
await loadCrmDealerList();
} catch {
ElMessage.error('处理失败');
}
}
async function loadSalesAreas() {
console.log('=== 开始加载销区列表 ===');
try {
const res = await getSalesAreas();
console.log('销区API响应:', res);
salesAreas.value = res?.data || [];
console.log('销区数量:', salesAreas.value.length);
} catch (error) {
console.error('加载销区失败:', error);
}
} catch { /* ignore */ }
}
async function loadBrands() {
@@ -85,15 +170,23 @@ async function loadBrands() {
function handleSearch() {
currentPage.value = 1;
loadDealerList();
if (dataSource.value === 'erp') {
loadErpDealerList();
} else {
loadCrmDealerList();
}
}
function handlePageChange(page: number) {
currentPage.value = page;
loadDealerList();
if (dataSource.value === 'erp') {
loadErpDealerList();
} else {
loadCrmDealerList();
}
}
// 状态标签
// ERP状态标签
function getStatusInfo(item: CustomerVO) {
if (item.isStop === 1) {
return { type: 'danger' as const, text: '已停用' };
@@ -101,10 +194,22 @@ function getStatusInfo(item: CustomerVO) {
return { type: 'success' as const, text: '合作中' };
}
// CRM生命周期标签
function getLifecycleInfo(item: CrmDealerVo) {
switch (item.lifecycle) {
case 'active':
return { type: 'success' as const, text: '活跃' };
case 'churn':
return { type: 'danger' as const, text: '流失' };
default:
return { type: 'info' as const, text: '未知' };
}
}
// 手机号脱敏
function maskPhone(phone: string) {
if (!phone || phone.length < 7) return phone || '--';
return phone.slice(0, 3) + '****' + phone.slice(-4);
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
// Logo 渐变色
@@ -119,14 +224,13 @@ const logoGradients = [
'linear-gradient(135deg, #64748b, #94a3b8)',
];
function getLogoBg(item: CustomerVO) {
const idx = item.customerCode.charCodeAt(item.customerCode.length - 1) % logoGradients.length;
function getLogoBg(code: string) {
const idx = code.charCodeAt(code.length - 1) % logoGradients.length;
return logoGradients[idx];
}
onMounted(() => {
console.log('=== 经销商页面已挂载 ===');
loadDealerList();
loadErpDealerList();
loadSalesAreas();
loadBrands();
});
@@ -139,15 +243,20 @@ onMounted(() => {
<div class="header-stats">
<div class="h-stat">
<span class="h-stat-value">{{ totalDealers }}</span>
<span class="h-stat-label">合作经销商</span>
<span class="h-stat-label">{{ dataSource === 'erp' ? '合作客户' : '经销商' }}</span>
</div>
</div>
<!-- 数据源切换 -->
<el-radio-group v-model="dataSource" @change="handleDataSourceChange">
<el-radio-button value="erp">ERP客户档案</el-radio-button>
<el-radio-button value="crm">CRM经销商</el-radio-button>
</el-radio-group>
</div>
<!-- Filter -->
<div class="filter-bar">
<!-- ERP筛选 -->
<div v-if="dataSource === 'erp'" class="filter-bar">
<el-select
v-model="filters.salesAreaCode"
v-model="erpFilters.salesAreaCode"
placeholder="选择销区"
clearable
style="width: 140px"
@@ -161,7 +270,7 @@ onMounted(() => {
/>
</el-select>
<el-select
v-model="filters.brand"
v-model="erpFilters.brand"
placeholder="选择品牌"
clearable
filterable
@@ -176,7 +285,7 @@ onMounted(() => {
/>
</el-select>
<el-select
v-model="filters.isStop"
v-model="erpFilters.isStop"
placeholder="状态"
clearable
style="width: 120px"
@@ -186,7 +295,42 @@ onMounted(() => {
<el-option label="已停用" value="1" />
</el-select>
<el-input
v-model="filters.keyword"
v-model="erpFilters.keyword"
placeholder="搜索客户名称、编码..."
clearable
style="width: 240px"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<span v-if="total > 0" class="total-info"> {{ total }} </span>
</div>
<!-- CRM筛选 -->
<div v-else class="filter-bar">
<el-select
v-model="crmFilters.lifecycle"
placeholder="生命周期"
clearable
style="width: 120px"
@change="handleSearch"
>
<el-option label="活跃" value="active" />
<el-option label="流失" value="churn" />
</el-select>
<el-select
v-model="crmFilters.level"
placeholder="等级"
clearable
style="width: 120px"
@change="handleSearch"
>
<el-option label="A级" value="A" />
<el-option label="B级" value="B" />
<el-option label="C级" value="C" />
</el-select>
<el-input
v-model="crmFilters.keyword"
placeholder="搜索经销商名称、编码..."
clearable
style="width: 240px"
@@ -194,18 +338,18 @@ onMounted(() => {
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<span class="total-info" v-if="total > 0"> {{ total }} </span>
<span v-if="total > 0" class="total-info"> {{ total }} </span>
</div>
<!-- Dealer Cards -->
<div class="dealer-grid" v-loading="loading">
<!-- ERP客户卡片 -->
<div v-if="dataSource === 'erp'" v-loading="loading" class="dealer-grid">
<div
v-for="dealer in dealerList"
v-for="dealer in erpDealerList"
:key="dealer.customerCode"
class="dealer-card"
>
<div class="card-header">
<div class="dealer-logo" :style="{ background: getLogoBg(dealer) }">
<div class="dealer-logo" :style="{ background: getLogoBg(dealer.customerCode) }">
{{ dealer.customerName.charAt(0) }}
</div>
<div class="dealer-identity">
@@ -249,14 +393,80 @@ onMounted(() => {
</div>
</div>
<!-- CRM经销商卡片 -->
<div v-else v-loading="loading" class="dealer-grid">
<div
v-for="dealer in crmDealerList"
:key="dealer.dealerId"
class="dealer-card"
>
<div class="card-header">
<div class="dealer-logo" :style="{ background: getLogoBg(dealer.dealerCode) }">
{{ dealer.dealerName.charAt(0) }}
</div>
<div class="dealer-identity">
<span class="dealer-name">{{ dealer.dealerName }}</span>
<span class="dealer-code">{{ dealer.dealerCode }}</span>
</div>
<el-tag :type="getLifecycleInfo(dealer).type" size="small" effect="light" round>
{{ getLifecycleInfo(dealer).text }}
</el-tag>
</div>
<div class="card-body">
<div class="detail-row">
<span class="detail-label">ERP编码</span>
<span class="detail-value">{{ dealer.customerCode || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">等级</span>
<span class="detail-value">{{ dealer.levelName || dealer.level || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">联系人</span>
<span class="detail-value">{{ dealer.contactName || '--' }} · {{ maskPhone(dealer.mobile || '') }}</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.totalOrderAmount?.toFixed(0) || '--' }}</span>
<span class="kpi-label">累计订单</span>
</div>
<div class="kpi-item">
<span class="kpi-value">{{ dealer.activityScore?.toFixed(1) || '--' }}</span>
<span class="kpi-label">活跃评分</span>
</div>
</div>
<!-- 同步状态 -->
<div class="sync-actions">
<el-tag v-if="dealer.hasPendingAlerts" type="warning" size="small" effect="plain">
存在差异
</el-tag>
<el-button
type="primary"
size="small"
:loading="syncLoading"
@click="handleSync(dealer)"
>
同步
</el-button>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && dealerList.length === 0" class="empty-state">
<el-empty description="暂无经销商数据" />
<div v-if="!loading && (dataSource === 'erp' ? erpDealerList.length === 0 : crmDealerList.length === 0)" class="empty-state">
<el-empty description="暂无数据" />
</div>
<!-- Pagination -->
<div class="pagination-bar" v-if="total > 0">
<span class="pagination-info"> {{ total }} 家经销商</span>
<div v-if="total > 0" class="pagination-bar">
<span class="pagination-info"> {{ total }} {{ dataSource === 'erp' ? '家客户' : '家经销商' }}</span>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
@@ -265,6 +475,29 @@ onMounted(() => {
@current-change="handlePageChange"
/>
</div>
<!-- 同步预警对话框 -->
<el-dialog v-model="showAlertsDialog" title="同步差异预警" width="600px">
<el-alert type="warning" :closable="false">
CRM维护的字段与ERP存在差异请确认是否需要更新
</el-alert>
<el-table :data="currentAlerts" style="margin-top: 16px">
<el-table-column prop="alertType" label="差异类型" />
<el-table-column prop="alertMessage" label="差异说明" />
<el-table-column prop="crmValue" label="CRM值" />
<el-table-column prop="erpValue" label="ERP值" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleResolveAlert(row.id, 'acknowledge')">
确认
</el-button>
<el-button type="info" link size="small" @click="handleResolveAlert(row.id, 'ignore')">
忽略
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
@@ -275,7 +508,7 @@ onMounted(() => {
.page-header {
display: flex;
align-items: left;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
@@ -449,6 +682,13 @@ onMounted(() => {
color: var(--color-text-secondary);
}
.sync-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.empty-state {
padding: 60px 0;
}

View File

@@ -1,8 +1,9 @@
<!-- ERP 测试页面 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { testErpConnection, erpHealth, getCustomerList, getSalesAreas, getBrands, type CustomerVO } from '@/api/erp';
import type { CustomerVO } from '@/api/erp';
import { ElMessage } from 'element-plus';
import { onMounted, ref } from 'vue';
import { erpHealth, getBrands, getCustomerList, getSalesAreas, testErpConnection } from '@/api/erp';
// 连接测试
const connectionStatus = ref<string>('');
@@ -18,14 +19,17 @@ async function checkConnection() {
connectionData.value = res.data;
if (res.status === 'connected') {
ElMessage.success('SQL Server 连接成功');
} else {
}
else {
ElMessage.error(`连接失败: ${res.data?.error}`);
}
} catch (error: any) {
}
catch (error: any) {
connectionStatus.value = 'error';
connectionData.value = { error: error?.message || '未知错误' };
ElMessage.error('请求失败');
} finally {
}
finally {
loading.value = false;
}
}
@@ -35,7 +39,8 @@ async function checkHealth() {
const res = await erpHealth();
healthStatus.value = res.msg || '正常';
ElMessage.success('ERP 服务运行正常');
} catch (error: any) {
}
catch (error: any) {
healthStatus.value = `异常: ${error?.message || '服务不可用'}`;
ElMessage.error('ERP 服务不可用');
}
@@ -65,9 +70,11 @@ async function loadCustomerList() {
});
customerList.value = res.rows || [];
total.value = res.total || 0;
} catch (error: any) {
}
catch (error: any) {
ElMessage.error(error?.message || '加载客户列表失败');
} finally {
}
finally {
customerLoading.value = false;
}
}
@@ -76,7 +83,8 @@ async function loadSalesAreas() {
try {
const res = await getSalesAreas();
salesAreas.value = res || [];
} catch {
}
catch {
// ignore
}
}
@@ -85,7 +93,8 @@ async function loadBrands() {
try {
const res = await getBrands();
brands.value = res || [];
} catch {
}
catch {
// ignore
}
}
@@ -197,8 +206,10 @@ onMounted(() => {
:value="item.brand"
/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<span class="total-info" v-if="total > 0"> {{ total }} </span>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<span v-if="total > 0" class="total-info"> {{ total }} </span>
</div>
<!-- 客户列表表格 -->

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { CrmLeadBo, CrmLeadFollowBo, CrmLeadFollowVo, CrmLeadVo, LeadConvertRequest } from '@/api/crm';
import type { CrmLeadBo, CrmLeadFollowBo, CrmLeadFollowVo, CrmLeadStatsVo, CrmLeadVo, LeadConvertRequest } from '@/api/crm';
import type { ErpCustomerSelectVo } from '@/api/crm';
import type { UserInfo } from '@/api/user';
import { ElMessage, ElMessageBox } from 'element-plus';
import { onMounted, ref } from 'vue';
import { addLeadFollow, assignLead, convertLeadToDealer, createLead, deleteLead, getLeadFollowRecords, getLeadList } from '@/api/crm';
import { addLeadFollow, assignLead, convertLeadToDealer, createLead, deleteLead, getLeadFollowRecords, getLeadList, getLeadStats, invalidateLead, restoreLead, updateLead, getErpCustomerSelect } from '@/api/crm';
import { getUserSelectList } from '@/api/user';
// 线索列表数据
@@ -11,6 +12,53 @@ const leadList = ref<CrmLeadVo[]>([]);
const leadLoading = ref(false);
const leadTotal = ref(0);
// Stats数据从后端获取全量统计
const statsData = ref<CrmLeadStatsVo | null>(null);
// 计算环比变化率
function calcChangeRate(current: number, previous: number): { rate: string, up: boolean } {
if (previous === 0) {
return { rate: current > 0 ? '+新增' : '0%', up: current > 0 };
}
const change = ((current - previous) / previous) * 100;
const sign = change >= 0 ? '+' : '';
return { rate: `${sign}${change.toFixed(1)}%`, up: change >= 0 };
}
// Stats展示数据
const leadStats = ref([
{ label: '线索总数', value: '0', icon: 'User' as const, change: '0%', up: true, bg: '#eef5ff', color: '#1d5af3' },
{ label: '高意向线索', value: '0', icon: 'StarFilled' as const, change: '0%', up: true, bg: '#fef2f2', color: '#dc2626' },
{ label: '本月新增', value: '0', icon: 'Calendar' as const, change: '0%', up: true, bg: '#f0fdf4', color: '#16a34a' },
{ label: '转化率', value: '0%', icon: 'DataAnalysis' as const, change: '0%', up: true, bg: '#faf5ff', color: '#9333ea' },
]);
// 加载统计数据
async function loadLeadStats() {
try {
const res = await getLeadStats();
if (res.data) {
statsData.value = res.data;
// 计算环比
const totalChange = calcChangeRate(res.data.monthlyNewCount, res.data.lastMonthNewCount);
const highIntentChange = calcChangeRate(res.data.monthlyHighIntentCount, res.data.lastMonthHighIntentCount);
const newChange = calcChangeRate(res.data.monthlyNewCount, res.data.lastMonthNewCount);
const conversionChange = calcChangeRate(res.data.monthlyConvertedCount, res.data.lastMonthConvertedCount);
leadStats.value = [
{ label: '线索总数', value: res.data.totalCount.toString(), icon: 'User' as const, change: totalChange.rate, up: totalChange.up, bg: '#eef5ff', color: '#1d5af3' },
{ label: '高意向线索', value: res.data.highIntentCount.toString(), icon: 'StarFilled' as const, change: highIntentChange.rate, up: highIntentChange.up, bg: '#fef2f2', color: '#dc2626' },
{ label: '本月新增', value: res.data.monthlyNewCount.toString(), icon: 'Calendar' as const, change: newChange.rate, up: newChange.up, bg: '#f0fdf4', color: '#16a34a' },
{ label: '转化率', value: `${res.data.conversionRate}%`, icon: 'DataAnalysis' as const, change: conversionChange.rate, up: conversionChange.up, bg: '#faf5ff', color: '#9333ea' },
];
}
}
catch (error: any) {
ElMessage.error(error?.message || '加载统计数据失败');
}
}
// 线索筛选参数
const leadFilters = ref({
keyword: '',
@@ -55,6 +103,27 @@ const leadForm = ref<CrmLeadBo>({
remark: '',
});
// 编辑线索Dialog
const showEditLeadDialog = ref(false);
const editLeadForm = ref<CrmLeadBo>({
leadId: 0,
companyName: '',
contactName: '',
mobile: '',
wechat: '',
province: '',
city: '',
regionId: undefined,
sourceType: '',
activityName: '',
referrerName: '',
industry: '',
companyScale: '',
storeCount: undefined,
ownerUserId: undefined,
remark: '',
});
// 手机号验证规则
const mobileValidator = (value: string) => {
if (!value) {
@@ -87,6 +156,31 @@ const convertForm = ref<LeadConvertRequest>({
level: 'C',
});
// ERP客户选择器
const erpCustomerSelectList = ref<ErpCustomerSelectVo[]>([]);
const erpSelectLoading = ref(false);
// 加载ERP客户选择列表
async function loadErpCustomerSelect(keyword?: string) {
erpSelectLoading.value = true;
try {
const res = await getErpCustomerSelect(keyword);
erpCustomerSelectList.value = res.data || [];
} catch {
ElMessage.error('加载ERP客户列表失败');
} finally {
erpSelectLoading.value = false;
}
}
// 选择ERP客户后自动填充经销商名称
async function handleErpCustomerSelect(customerCode: string) {
const customer = erpCustomerSelectList.value.find(c => c.customerCode === customerCode);
if (customer) {
convertForm.value.dealerName = customer.customerName;
}
}
// 加载线索列表
async function loadLeads() {
leadLoading.value = true;
@@ -218,12 +312,112 @@ async function submitLead() {
ElMessage.success('线索创建成功');
showAddLeadDialog.value = false;
await loadLeads();
await loadLeadStats();
}
catch (error: any) {
ElMessage.error(error?.message || '创建线索失败');
}
}
// 打开编辑线索Dialog
function openEditLeadDialog(lead: CrmLeadVo) {
editLeadForm.value = {
leadId: lead.leadId,
companyName: lead.companyName,
contactName: lead.contactName,
mobile: lead.mobile,
wechat: lead.wechat || '',
province: lead.province || '',
city: lead.city || '',
regionId: lead.regionId,
sourceType: lead.sourceType || '',
activityName: lead.activityName || '',
referrerName: lead.referrerName || '',
industry: lead.industry || '',
companyScale: lead.companyScale || '',
storeCount: lead.storeCount,
ownerUserId: lead.ownerUserId,
remark: lead.remark || '',
};
showEditLeadDialog.value = true;
loadUserList();
}
// 提交编辑线索
async function submitEditLead() {
if (!editLeadForm.value.companyName || !editLeadForm.value.contactName || !editLeadForm.value.mobile) {
ElMessage.warning('请填写必填信息');
return;
}
const mobileError = mobileValidator(editLeadForm.value.mobile);
if (mobileError) {
ElMessage.warning(mobileError);
return;
}
try {
await updateLead(editLeadForm.value);
ElMessage.success('线索更新成功');
showEditLeadDialog.value = false;
await loadLeads();
await loadLeadStats();
}
catch (error: any) {
ElMessage.error(error?.message || '更新线索失败');
}
}
// 作废线索
async function handleInvalidateLead(lead: CrmLeadVo) {
try {
await ElMessageBox.confirm(
`确定要作废线索"${lead.companyName}"吗?作废后线索将不再显示在正常列表中。`,
'作废确认',
{
confirmButtonText: '确定作废',
cancelButtonText: '取消',
type: 'warning',
},
);
await invalidateLead(lead.leadId);
ElMessage.success('线索已作废');
await loadLeads();
await loadLeadStats();
}
catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '作废失败');
}
}
}
// 恢复线索
async function handleRestoreLead(lead: CrmLeadVo) {
try {
await ElMessageBox.confirm(
`确定要恢复线索"${lead.companyName}"吗?恢复后线索将重新进入正常流程。`,
'恢复确认',
{
confirmButtonText: '确定恢复',
cancelButtonText: '取消',
type: 'info',
},
);
await restoreLead(lead.leadId);
ElMessage.success('线索已恢复');
await loadLeads();
await loadLeadStats();
}
catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '恢复失败');
}
}
}
// 打开转化Dialog
function convertToDealer(lead: CrmLeadVo) {
convertForm.value = {
@@ -235,6 +429,7 @@ function convertToDealer(lead: CrmLeadVo) {
level: 'C',
};
showConvertDialog.value = true;
loadErpCustomerSelect(); // 加载ERP客户选择列表
}
// 提交转化
@@ -244,11 +439,18 @@ async function submitConvert() {
return;
}
// 校验ERP客户编码必填
if (!convertForm.value.customerCode) {
ElMessage.warning('请选择ERP客户编码经销商必须绑定ERP客户');
return;
}
try {
await convertLeadToDealer(convertForm.value);
ElMessage.success('线索转化成功,已创建经销商');
showConvertDialog.value = false;
loadLeads();
loadLeadStats();
}
catch (error: any) {
ElMessage.error(error?.message || '转化失败');
@@ -317,6 +519,7 @@ async function handleDeleteLead(lead: CrmLeadVo) {
}
onMounted(() => {
loadLeadStats();
loadLeads();
});
</script>
@@ -326,7 +529,32 @@ onMounted(() => {
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">线索中心</h1>
<p class="page-desc">管理销售线索,跟进转化</p>
<p class="page-desc">管理潜在客户线索跟进转化</p>
</div>
<!-- Header Stats -->
<div class="lead-stats">
<div
v-for="s in leadStats"
:key="s.label"
class="lead-stat-card"
>
<div class="stat-icon-wrap" :style="{ background: s.bg }">
<el-icon :size="22" :style="{ color: s.color }">
<component :is="s.icon" />
</el-icon>
</div>
<div class="stat-body">
<span class="stat-num">{{ s.value }}</span>
<span class="stat-lbl">{{ s.label }}</span>
</div>
<div class="stat-change" :class="s.up ? 'up' : 'down'">
<el-icon :size="12">
<component :is="s.up ? 'Top' : 'Bottom'" />
</el-icon>
{{ s.change }}
</div>
</div>
</div>
<!-- 筛选栏 -->
@@ -387,19 +615,14 @@ onMounted(() => {
{{ maskPhone(row.mobile) }}
</template>
</el-table-column>
<el-table-column prop="customerCode" label="ERP编码" width="120">
<el-table-column prop="intentLevelName" label="AI意向" width="120">
<template #default="{ row }">
<el-link v-if="row.customerCode" type="primary" @click="viewErpCustomer(row)">
{{ row.customerCode }}
</el-link>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column prop="intentLevelName" label="AI意向" width="100">
<template #default="{ row }">
<el-badge :value="row.aiScore || 0" :type="row.intentLevel ? getIntentBadgeType(row.intentLevel) : 'info'">
{{ row.intentLevelName || '--' }}
</el-badge>
<div class="ai-intent-cell">
<el-tag :type="row.intentLevel ? getIntentBadgeType(row.intentLevel) : 'info'" size="small">
{{ row.intentLevelName || '--' }}
</el-tag>
<span class="ai-score">{{ Math.round(row.aiScore || 0) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="ownerUserName" label="负责人" width="100">
@@ -412,35 +635,70 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="leadStatusName" label="状态" width="100" />
<el-table-column prop="createTime" label="创建时间" width="160" />
<el-table-column label="操作" width="280" fixed="right">
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showLeadDetail(row)">
详情
</el-button>
<el-button type="primary" link size="small" @click="openFollowDrawer(row)">
跟进
</el-button>
<el-button
v-if="row.leadStatus !== 'converted'"
type="warning"
link
size="small"
@click="openAssignDialog(row)"
>
分配
</el-button>
<el-button
v-if="row.leadStatus !== 'converted'"
type="success"
link
size="small"
@click="convertToDealer(row)"
>
转经销商
</el-button>
<el-button type="danger" link size="small" @click="handleDeleteLead(row)">
删除
</el-button>
<div class="operation-buttons">
<el-button type="primary" link size="small" @click="showLeadDetail(row)">
详情
</el-button>
<el-button
v-if="row.leadStatus !== 'converted' && row.leadStatus !== 'invalid'"
type="primary"
link
size="small"
@click="openEditLeadDialog(row)"
>
编辑
</el-button>
<el-button type="primary" link size="small" @click="openFollowDrawer(row)">
跟进
</el-button>
<el-button
v-if="row.leadStatus !== 'converted' && row.leadStatus !== 'invalid'"
type="warning"
link
size="small"
@click="openAssignDialog(row)"
>
分配
</el-button>
<el-button
v-if="row.leadStatus !== 'converted' && row.leadStatus !== 'invalid'"
type="success"
link
size="small"
@click="convertToDealer(row)"
>
转经销商
</el-button>
<el-button
v-if="row.leadStatus !== 'converted' && row.leadStatus !== 'invalid'"
type="info"
link
size="small"
@click="handleInvalidateLead(row)"
>
作废
</el-button>
<el-button
v-if="row.leadStatus === 'invalid'"
type="success"
link
size="small"
@click="handleRestoreLead(row)"
>
恢复
</el-button>
<el-button
v-if="row.leadStatus === 'invalid'"
type="danger"
link
size="small"
@click="handleDeleteLead(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
@@ -483,7 +741,11 @@ onMounted(() => {
{{ currentLead.storeCount || '--' }}
</el-descriptions-item>
<el-descriptions-item label="AI意向等级">
<el-progress :percentage="currentLead.aiScore || 0" :color="currentLead.intentLevel ? getIntentBadgeType(currentLead.intentLevel) === 'danger' ? '#dc2626' : getIntentBadgeType(currentLead.intentLevel) === 'warning' ? '#f59e0b' : '#0ea5e9' : '#0ea5e9'" />
<el-progress
:percentage="Math.round(currentLead.aiScore || 0)"
:stroke-width="20"
:color="currentLead.intentLevel ? getIntentBadgeType(currentLead.intentLevel) === 'danger' ? '#dc2626' : getIntentBadgeType(currentLead.intentLevel) === 'warning' ? '#f59e0b' : '#0ea5e9' : '#0ea5e9'"
/>
</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="currentLead.riskLevel === 'high' ? 'danger' : currentLead.riskLevel === 'medium' ? 'warning' : 'info'">
@@ -582,7 +844,7 @@ onMounted(() => {
<el-form-item label="微信号">
<el-input v-model="leadForm.wechat" placeholder="请输入微信号(可选)" />
</el-form-item>
<el-row :gutter="16">
<el-row :gutter="16" style="margin-bottom: 18px">
<el-col :span="12">
<el-form-item label="省份">
<el-input v-model="leadForm.province" placeholder="请输入省份" />
@@ -631,6 +893,70 @@ onMounted(() => {
</template>
</el-dialog>
<!-- 编辑线索Dialog -->
<el-dialog v-model="showEditLeadDialog" title="编辑线索" width="600px" :close-on-click-modal="false">
<el-form :model="editLeadForm" label-width="100px">
<el-form-item label="公司名称" required>
<el-input v-model="editLeadForm.companyName" placeholder="请输入公司名称" />
</el-form-item>
<el-form-item label="联系人" required>
<el-input v-model="editLeadForm.contactName" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="手机号" required>
<el-input v-model="editLeadForm.mobile" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="微信号">
<el-input v-model="editLeadForm.wechat" placeholder="请输入微信号(可选)" />
</el-form-item>
<el-row :gutter="16" style="margin-bottom: 18px">
<el-col :span="12">
<el-form-item label="省份">
<el-input v-model="editLeadForm.province" placeholder="请输入省份" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="城市">
<el-input v-model="editLeadForm.city" placeholder="请输入城市" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="来源类型">
<el-select v-model="editLeadForm.sourceType" placeholder="选择来源类型" style="width: 100%">
<el-option label="活动" value="activity" />
<el-option label="推荐" value="referral" />
<el-option label="网站" value="website" />
<el-option label="展会" value="exhibition" />
<el-option label="企业微信" value="wecom" />
<el-option label="ERP客户" value="erp" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item v-if="editLeadForm.sourceType === 'activity'" label="活动名称">
<el-input v-model="editLeadForm.activityName" placeholder="请输入活动名称" />
</el-form-item>
<el-form-item v-if="editLeadForm.sourceType === 'referral'" label="推荐人">
<el-input v-model="editLeadForm.referrerName" placeholder="请输入推荐人" />
</el-form-item>
<el-form-item label="行业">
<el-input v-model="editLeadForm.industry" placeholder="请输入行业" />
</el-form-item>
<el-form-item label="门店数">
<el-input-number v-model="editLeadForm.storeCount" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editLeadForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditLeadDialog = false">
取消
</el-button>
<el-button type="primary" @click="submitEditLead">
保存修改
</el-button>
</template>
</el-dialog>
<!-- 分配线索Dialog -->
<el-dialog v-model="showAssignDialog" title="分配线索" width="500px" :close-on-click-modal="false">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
@@ -675,19 +1001,38 @@ onMounted(() => {
<!-- 转经销商Dialog -->
<el-dialog v-model="showConvertDialog" title="线索转经销商" width="600px" :close-on-click-modal="false">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
将线索转化为正式经销商,创建经销商档案
转化为经销商必须绑定ERP客户编码
</el-alert>
<el-form :model="convertForm" label-width="120px">
<el-form-item label="经销商名称" required>
<el-input v-model="convertForm.dealerName" placeholder="默认为线索公司名称" />
<el-form-item label="ERP客户编码" required>
<el-select
v-model="convertForm.customerCode"
placeholder="请选择ERP客户"
filterable
:loading="erpSelectLoading"
style="width: 100%"
@change="handleErpCustomerSelect"
>
<el-option
v-for="customer in erpCustomerSelectList"
:key="customer.customerCode"
:label="`${customer.customerName} (${customer.customerCode})`"
:value="customer.customerCode"
>
<div style="display: flex; justify-content: space-between">
<span>{{ customer.customerName }}</span>
<span style="color: #909399">{{ customer.customerCode }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="经销商名称">
<el-input v-model="convertForm.dealerName" placeholder="自动从ERP获取" disabled />
</el-form-item>
<el-form-item label="经销商编码" required>
<el-input v-model="convertForm.dealerCode" placeholder="请输入经销商编码,如DL20260001" />
</el-form-item>
<el-form-item label="ERP客户编码">
<el-input v-model="convertForm.customerCode" placeholder="可选,关联ERP客户" />
</el-form-item>
<el-form-item label="签约时间">
<el-date-picker
v-model="convertForm.signedAt"
@@ -742,6 +1087,70 @@ onMounted(() => {
margin: 0;
}
.lead-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.lead-stat-card {
background: #fff;
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(0, 0, 0, 0.04);
transition: all 0.25s ease;
&:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-body {
display: flex;
flex-direction: column;
flex: 1;
}
.stat-num {
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.stat-lbl {
font-size: 12.5px;
color: var(--color-text-secondary);
margin-top: 2px;
}
.stat-change {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 2px;
&.up { color: #16a34a; }
&.down { color: #dc2626; }
}
.leads-filter-bar {
display: flex;
align-items: center;
@@ -798,7 +1207,34 @@ onMounted(() => {
color: var(--color-text-secondary);
}
.operation-buttons {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 2px;
}
.operation-buttons .el-button {
padding: 4px 1px;
}
.ai-intent-cell {
display: flex;
align-items: center;
gap: 8px;
}
.ai-score {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 500;
}
@media (max-width: 768px) {
.lead-stats {
grid-template-columns: repeat(2, 1fr);
}
.leads-filter-bar {
flex-direction: column;
align-items: stretch;

View File

@@ -2,11 +2,11 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from 'element-plus';
import type { LoginDTO } from '@/api/auth/types';
import { computed, reactive, ref, watch, onMounted } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { login } from '@/api';
import { useUserStore } from '@/stores';
import { useLoginTenantId } from '@/hooks/useLoginTenantId';
import { useUserStore } from '@/stores';
// 定义 emits
const emit = defineEmits<{
@@ -90,7 +90,8 @@ function loadRemembered() {
// console.log('已恢复记住的登录信息租户ID:', data.tenantId);
}
}
} catch { /* ignore */ }
}
catch { /* ignore */ }
}
// 保存记住的登录信息
@@ -101,7 +102,8 @@ function saveRemembered() {
username: formModel.username,
password: formModel.password,
}));
} else {
}
else {
localStorage.removeItem(REMEMBER_KEY);
}
}
@@ -270,7 +272,9 @@ async function handleSubmit() {
</el-button>
</el-form-item>
<el-form-item>
<el-checkbox v-model="rememberMe">记住登录</el-checkbox>
<el-checkbox v-model="rememberMe">
记住登录
</el-checkbox>
</el-form-item>
</el-form>

View File

@@ -1,39 +1,6 @@
<template>
<div class="login-page">
<div class="login-container">
<div class="left-section">
<div class="logo-wrap">
<svg width="40" height="40" viewBox="0 0 28 28" fill="none">
<rect width="28" height="28" rx="8" fill="#1d5af3"/>
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
</svg>
<span class="logo-text">{{ companyName }} 企业员工门户</span>
</div>
<div class="ad-banner">
<el-icon :size="200" color="#1d5af3">
<User />
</el-icon>
</div>
</div>
<div class="right-section">
<div class="content-wrapper">
<div class="form-container">
<span class="content-title">登录后访问完整功能</span>
<el-divider content-position="center">
账号密码登录
</el-divider>
<TenantAccountPassword @success="handleLoginSuccess" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import TenantAccountPassword from './components/TenantAccountPassword.vue';
const router = useRouter();
@@ -60,6 +27,39 @@ function handleLoginSuccess() {
}
</script>
<template>
<div class="login-page">
<div class="login-container">
<div class="left-section">
<div class="logo-wrap">
<svg width="40" height="40" viewBox="0 0 28 28" fill="none">
<rect width="28" height="28" rx="8" fill="#1d5af3" />
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5" />
</svg>
<span class="logo-text">{{ companyName }} 企业员工门户</span>
</div>
<div class="ad-banner">
<el-icon :size="200" color="#1d5af3">
<User />
</el-icon>
</div>
</div>
<div class="right-section">
<div class="content-wrapper">
<div class="form-container">
<span class="content-title">登录后访问完整功能</span>
<el-divider content-position="center">
账号密码登录
</el-divider>
<TenantAccountPassword @success="handleLoginSuccess" />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.login-page {
display: flex;

View File

@@ -1,3 +1,7 @@
<script setup lang="ts">
// Menu settings page
</script>
<template>
<div class="menu-settings">
<div class="page-header">
@@ -10,10 +14,6 @@
</div>
</template>
<script setup lang="ts">
// Menu settings page
</script>
<style scoped lang="scss">
.menu-settings {
.page-header {

View File

@@ -1,53 +1,10 @@
<template>
<div class="basic-setting">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
class="setting-form"
>
<el-form-item label="用户ID" prop="userId" v-show="false">
<el-input v-model="formData.userId" />
</el-form-item>
<el-form-item label="昵称" prop="nickName">
<el-input v-model="formData.nickName" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio-group v-model="formData.sex">
<el-radio value="0"></el-radio>
<el-radio value="1"></el-radio>
<el-radio value="2">保密</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phonenumber">
<el-input v-model="formData.phonenumber" placeholder="请输入手机号" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="loading">
保存修改
</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus';
import type { UserProfile } from '@/api/profile/types';
import { ElMessage } from 'element-plus';
import { userProfileUpdate } from '@/api/profile';
import { useUserStore } from '@/stores';
import { ElMessage } from 'element-plus';
const props = defineProps<{ profile?: UserProfile }>();
const emit = defineEmits<{ update: [] }>();
@@ -88,19 +45,23 @@ watch(() => props.profile, (newProfile) => {
}, { immediate: true });
async function handleSubmit() {
if (!formRef.value) return;
if (!formRef.value)
return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const res = await userProfileUpdate(formData);
if (res instanceof Error) return;
if (res instanceof Error)
return;
ElMessage.success('修改成功');
emit('update');
} catch {
}
catch {
// 拦截器已显示错误信息
} finally {
}
finally {
loading.value = false;
}
}
@@ -118,6 +79,57 @@ function resetForm() {
}
</script>
<template>
<div class="basic-setting">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
class="setting-form"
>
<el-form-item v-show="false" label="用户ID" prop="userId">
<el-input v-model="formData.userId" />
</el-form-item>
<el-form-item label="昵称" prop="nickName">
<el-input v-model="formData.nickName" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio-group v-model="formData.sex">
<el-radio value="0">
</el-radio>
<el-radio value="1">
</el-radio>
<el-radio value="2">
保密
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phonenumber">
<el-input v-model="formData.phonenumber" placeholder="请输入手机号" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleSubmit">
保存修改
</el-button>
<el-button @click="resetForm">
重置
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<style scoped lang="scss">
.basic-setting {
padding: 16px 0;

View File

@@ -1,3 +1,83 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus';
import type { UpdatePasswordParam } from '@/api/profile/types';
import { ElMessage } from 'element-plus';
import { userUpdatePassword } from '@/api/profile';
import { useUserStore } from '@/stores';
const formRef = ref<FormInstance>();
const loading = ref(false);
const userStore = useUserStore();
const formData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
function validateConfirmPassword(rule: any, value: any, callback: any) {
if (value !== formData.newPassword) {
callback(new Error('两次输入的密码不一致'));
}
else {
callback();
}
}
const rules: FormRules = {
oldPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' },
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
};
async function handleSubmit() {
if (!formRef.value)
return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const params: UpdatePasswordParam = {
oldPassword: formData.oldPassword,
newPassword: formData.newPassword,
};
const res = await userUpdatePassword(params);
// hook-fetch 会将 afterResponse 的 reject 转为 resolve 返回 Error
if (res instanceof Error)
return;
ElMessage.success('密码修改成功,请重新登录');
// 清空表单和用户状态
resetForm();
userStore.logout();
}
catch {
// 拦截器已显示错误信息
}
finally {
loading.value = false;
}
}
});
}
function resetForm() {
formData.oldPassword = '';
formData.newPassword = '';
formData.confirmPassword = '';
formRef.value?.clearValidate();
}
</script>
<template>
<div class="security-setting">
<el-card class="setting-card" shadow="never">
@@ -42,91 +122,18 @@
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="loading">
<el-button type="primary" :loading="loading" @click="handleSubmit">
修改密码
</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button @click="resetForm">
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus';
import type { UpdatePasswordParam } from '@/api/profile/types';
import { userUpdatePassword } from '@/api/profile';
import { useUserStore } from '@/stores';
import { ElMessage } from 'element-plus';
const formRef = ref<FormInstance>();
const loading = ref(false);
const userStore = useUserStore();
const formData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
if (value !== formData.newPassword) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
};
const rules: FormRules = {
oldPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' },
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
};
async function handleSubmit() {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const params: UpdatePasswordParam = {
oldPassword: formData.oldPassword,
newPassword: formData.newPassword,
};
const res = await userUpdatePassword(params);
// hook-fetch 会将 afterResponse 的 reject 转为 resolve 返回 Error
if (res instanceof Error) return;
ElMessage.success('密码修改成功,请重新登录');
// 清空表单和用户状态
resetForm();
userStore.logout();
} catch {
// 拦截器已显示错误信息
} finally {
loading.value = false;
}
}
});
}
function resetForm() {
formData.oldPassword = '';
formData.newPassword = '';
formData.confirmPassword = '';
formRef.value?.clearValidate();
}
</script>
<style scoped lang="scss">
.security-setting {
padding: 16px 0;

View File

@@ -1,103 +1,10 @@
<template>
<div class="profile-page">
<div class="profile-container">
<!-- 左侧个人信息卡片 -->
<div class="profile-left">
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>个人信息</span>
<el-button
type="primary"
size="small"
@click="loadProfile"
:loading="loading"
>
刷新
</el-button>
</div>
</template>
<div v-loading="loading" element-loading-text="加载中...">
<div v-if="!profile && !loading" class="error-state">
<el-empty description="未能加载用户信息">
<el-button type="primary" @click="loadProfile">重新加载</el-button>
</el-empty>
</div>
<div v-else-if="profile">
<div class="avatar-section">
<el-upload
class="avatar-uploader"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:http-request="handleAvatarUpload"
>
<el-avatar :size="120" :src="avatarUrl" class="user-avatar">
{{ profile?.user.nickName?.charAt(0) || 'U' }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="24"><Camera /></el-icon>
<span>更换头像</span>
</div>
</el-upload>
<h2 class="user-name">{{ profile?.user.nickName || '未知' }}</h2>
</div>
<el-descriptions :column="1" class="user-info">
<el-descriptions-item label="账号">
{{ profile?.user.userName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="手机号码">
{{ profile?.user.phonenumber || '未绑定手机号' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ profile?.user.email || '未绑定邮箱' }}
</el-descriptions-item>
<el-descriptions-item label="部门">
<el-tag type="primary" size="small">
{{ profile?.user.deptName || '未分配部门' }}
</el-tag>
<el-tag v-if="profile?.postGroup" type="success" size="small" class="ml-2">
{{ profile.postGroup }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ profile?.roleGroup || '-' }}
</el-descriptions-item>
<el-descriptions-item label="上次登录">
{{ profile?.user.loginDate || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
</div>
<!-- 右侧设置面板 -->
<div class="profile-right">
<el-card class="settings-card">
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="基本设置" name="basic">
<BasicSetting :profile="profile" @update="loadProfile" />
</el-tab-pane>
<el-tab-pane label="安全设置" name="security">
<SecuritySetting :profile="profile" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { UserProfile } from '@/api/profile/types';
import type { UploadRequestOptions } from 'element-plus';
import type { UserProfile } from '@/api/profile/types';
import { ElMessage } from 'element-plus';
import { userProfile, userUpdateAvatar } from '@/api/profile';
import { useUserStore } from '@/stores';
import { ElMessage } from 'element-plus';
import BasicSetting from './components/BasicSetting.vue';
import SecuritySetting from './components/SecuritySetting.vue';
@@ -152,14 +59,17 @@ async function loadProfile() {
};
userStore.setUserInfo(updatedUserInfo);
} else {
}
else {
console.error('[Profile] 响应数据格式错误:', resp);
ElMessage.error('用户信息数据格式错误,请查看控制台');
}
} catch (error) {
}
catch (error) {
console.error('[Profile] 加载用户信息失败:', error);
ElMessage.error('加载用户信息失败,请检查网络连接');
} finally {
}
finally {
loading.value = false;
}
}
@@ -183,7 +93,8 @@ function beforeAvatarUpload(file: File) {
// 上传头像
async function handleAvatarUpload(options: UploadRequestOptions) {
const res = await userUpdateAvatar(options.file as File);
if (res instanceof Error) return;
if (res instanceof Error)
return;
ElMessage.success('头像更新成功');
await loadProfile();
}
@@ -191,6 +102,105 @@ async function handleAvatarUpload(options: UploadRequestOptions) {
onMounted(loadProfile);
</script>
<template>
<div class="profile-page">
<div class="profile-container">
<!-- 左侧个人信息卡片 -->
<div class="profile-left">
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>个人信息</span>
<el-button
type="primary"
size="small"
:loading="loading"
@click="loadProfile"
>
刷新
</el-button>
</div>
</template>
<div v-loading="loading" element-loading-text="加载中...">
<div v-if="!profile && !loading" class="error-state">
<el-empty description="未能加载用户信息">
<el-button type="primary" @click="loadProfile">
重新加载
</el-button>
</el-empty>
</div>
<div v-else-if="profile">
<div class="avatar-section">
<el-upload
class="avatar-uploader"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:http-request="handleAvatarUpload"
>
<el-avatar :size="120" :src="avatarUrl" class="user-avatar">
{{ profile?.user.nickName?.charAt(0) || 'U' }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="24">
<Camera />
</el-icon>
<span>更换头像</span>
</div>
</el-upload>
<h2 class="user-name">
{{ profile?.user.nickName || '未知' }}
</h2>
</div>
<el-descriptions :column="1" class="user-info">
<el-descriptions-item label="账号">
{{ profile?.user.userName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="手机号码">
{{ profile?.user.phonenumber || '未绑定手机号' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ profile?.user.email || '未绑定邮箱' }}
</el-descriptions-item>
<el-descriptions-item label="部门">
<el-tag type="primary" size="small">
{{ profile?.user.deptName || '未分配部门' }}
</el-tag>
<el-tag v-if="profile?.postGroup" type="success" size="small" class="ml-2">
{{ profile.postGroup }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ profile?.roleGroup || '-' }}
</el-descriptions-item>
<el-descriptions-item label="上次登录">
{{ profile?.user.loginDate || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
</div>
<!-- 右侧设置面板 -->
<div class="profile-right">
<el-card class="settings-card">
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="基本设置" name="basic">
<BasicSetting :profile="profile" @update="loadProfile" />
</el-tab-pane>
<el-tab-pane label="安全设置" name="security">
<SecuritySetting :profile="profile" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.profile-page {
padding: 24px;

View File

@@ -6,8 +6,12 @@
<template>
<div class="supply-container">
<div class="module-header">
<h1 class="module-title">供应链管理</h1>
<p class="module-subtitle">采购仓储物流一体化管理</p>
<h1 class="module-title">
供应链管理
</h1>
<p class="module-subtitle">
采购仓储物流一体化管理
</p>
</div>
<div class="module-content">
@@ -15,7 +19,9 @@
<el-icon class="placeholder-icon" color="#ef4444" :size="64">
<Truck />
</el-icon>
<h2 class="placeholder-title">功能开发中敬请期待</h2>
<h2 class="placeholder-title">
功能开发中敬请期待
</h2>
<p class="placeholder-text">
供应链管理模块将提供采购管理库存管理订单管理
物流跟踪供应商管理等功能支持全流程供应链协同

View File

@@ -1,3 +1,7 @@
<script setup lang="ts">
// System settings page
</script>
<template>
<div class="system-settings">
<div class="page-header">
@@ -10,10 +14,6 @@
</div>
</template>
<script setup lang="ts">
// System settings page
</script>
<style scoped lang="scss">
.system-settings {
.page-header {

View File

@@ -9,5 +9,5 @@ export default store;
// export * from './modules/chat';
export * from './modules/design';
export * from './modules/user';
export * from './modules/tabbar';
export * from './modules/user';

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useKeepAliveStore = defineStore(
'keep-alive',

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useUserStore } from '@/stores/modules/user';
export const useLockScreenStore = defineStore(
@@ -41,7 +41,8 @@ export const useLockScreenStore = defineStore(
return true;
}
return false;
} catch (e) {
}
catch (e) {
console.error('解锁请求异常:', e);
return false;
}

View File

@@ -45,7 +45,6 @@ export const useSessionStore = defineStore('session', () => {
// 获取会话列表(核心分页方法)
const requestSessionList = async (page: number = currentPage.value, force: boolean = false) => {
// 如果没有token就直接清空
if (!userStore.token) {
sessionList.value = [];
@@ -99,13 +98,11 @@ export const useSessionStore = defineStore('session', () => {
];
}
// 判断是否还有更多数据(当前页数据量 < pageSize 则无更多)
if (!force)
hasMore.value = (res?.length || 0) === pageSize.value;
if (!force)
currentPage.value = page; // 仅非强制刷新时更新页码
}
catch (error) {
console.error('[requestSessionList] 错误详情:', error);

View File

@@ -37,9 +37,9 @@ export const useTabbarStore = defineStore('tabbar', {
},
getters: {
getTabs: (state) => state.tabs,
getActiveTab: (state) => state.activeTab,
getCachedTabs: (state) => state.cachedTabs,
getTabs: state => state.tabs,
getActiveTab: state => state.activeTab,
getCachedTabs: state => state.cachedTabs,
},
actions: {
@@ -59,7 +59,7 @@ export const useTabbarStore = defineStore('tabbar', {
const affix = meta.affix === true;
// 检查是否已存在
const isExist = this.tabs.some((tab) => tab.path === path);
const isExist = this.tabs.some(tab => tab.path === path);
if (!isExist) {
this.tabs.push({
path,
@@ -87,8 +87,9 @@ export const useTabbarStore = defineStore('tabbar', {
* 关闭标签页
*/
closeTab(path: string) {
const index = this.tabs.findIndex((tab) => tab.path === path);
if (index === -1) return;
const index = this.tabs.findIndex(tab => tab.path === path);
if (index === -1)
return;
const tab = this.tabs[index];
@@ -112,7 +113,8 @@ export const useTabbarStore = defineStore('tabbar', {
if (nextTab) {
this.activeTab = nextTab.path;
router.push(nextTab.path);
} else {
}
else {
// 没有其他标签,跳转到首页
this.activeTab = HOME_PATH;
router.push(HOME_PATH);
@@ -125,7 +127,7 @@ export const useTabbarStore = defineStore('tabbar', {
*/
closeOtherTabs(path?: string) {
const currentPath = path || this.activeTab;
this.tabs = this.tabs.filter((tab) => tab.path === currentPath || tab.affix);
this.tabs = this.tabs.filter(tab => tab.path === currentPath || tab.affix);
// 更新缓存
this.cachedTabs = [];
@@ -136,7 +138,7 @@ export const useTabbarStore = defineStore('tabbar', {
});
// 如果当前活动标签被关闭,切换到第一个标签
if (!this.tabs.find((tab) => tab.path === this.activeTab)) {
if (!this.tabs.find(tab => tab.path === this.activeTab)) {
const firstTab = this.tabs[0];
if (firstTab) {
this.activeTab = firstTab.path;
@@ -149,7 +151,7 @@ export const useTabbarStore = defineStore('tabbar', {
* 关闭所有标签页(除了固定的)
*/
closeAllTabs() {
this.tabs = this.tabs.filter((tab) => tab.affix);
this.tabs = this.tabs.filter(tab => tab.affix);
this.cachedTabs = [];
this.tabs.forEach((tab) => {
if (tab.name) {
@@ -162,7 +164,8 @@ export const useTabbarStore = defineStore('tabbar', {
if (firstTab) {
this.activeTab = firstTab.path;
router.push(firstTab.path);
} else {
}
else {
this.activeTab = HOME_PATH;
router.push(HOME_PATH);
}
@@ -173,8 +176,9 @@ export const useTabbarStore = defineStore('tabbar', {
*/
closeRightTabs(path?: string) {
const currentPath = path || this.activeTab;
const index = this.tabs.findIndex((tab) => tab.path === currentPath);
if (index === -1) return;
const index = this.tabs.findIndex(tab => tab.path === currentPath);
if (index === -1)
return;
this.tabs = this.tabs.filter((tab, i) => i <= index || tab.affix);
@@ -187,7 +191,7 @@ export const useTabbarStore = defineStore('tabbar', {
});
// 如果当前活动标签被关闭,切换到指定标签
if (!this.tabs.find((tab) => tab.path === this.activeTab)) {
if (!this.tabs.find(tab => tab.path === this.activeTab)) {
this.activeTab = currentPath;
router.push(currentPath);
}
@@ -198,8 +202,9 @@ export const useTabbarStore = defineStore('tabbar', {
*/
closeLeftTabs(path?: string) {
const currentPath = path || this.activeTab;
const index = this.tabs.findIndex((tab) => tab.path === currentPath);
if (index === -1) return;
const index = this.tabs.findIndex(tab => tab.path === currentPath);
if (index === -1)
return;
this.tabs = this.tabs.filter((tab, i) => i >= index || tab.affix);
@@ -212,7 +217,7 @@ export const useTabbarStore = defineStore('tabbar', {
});
// 如果当前活动标签被关闭,切换到指定标签
if (!this.tabs.find((tab) => tab.path === this.activeTab)) {
if (!this.tabs.find(tab => tab.path === this.activeTab)) {
this.activeTab = currentPath;
router.push(currentPath);
}
@@ -222,7 +227,7 @@ export const useTabbarStore = defineStore('tabbar', {
* 固定/取消固定标签页
*/
toggleAffixTab(path: string) {
const tab = this.tabs.find((t) => t.path === path);
const tab = this.tabs.find(t => t.path === path);
if (tab) {
tab.affix = !tab.affix;
}
@@ -271,7 +276,7 @@ export const useTabbarStore = defineStore('tabbar', {
// 添加固定标签页
affixTabs.forEach((tab) => {
if (!this.tabs.find((t) => t.path === tab.path)) {
if (!this.tabs.find(t => t.path === tab.path)) {
this.tabs.push(tab);
if (tab.name && !this.cachedTabs.includes(tab.name)) {
this.cachedTabs.push(tab.name);

View File

@@ -1,6 +1,6 @@
import type { LoginUser } from '@/api/auth/types';
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTabbarStore } from './tabbar';

View File

@@ -17,7 +17,7 @@ function convertJsObjectToJson(jsCode: string): string {
}
// 为无引号的键添加引号key: value -> "key": value
cleaned = cleaned.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
cleaned = cleaned.replace(/([{,]\s*)([a-z_$][\w$]*)\s*:/gi, '$1"$2":');
// 处理单引号字符串,转换为双引号
cleaned = cleaned.replace(/'([^']*)'/g, '"$1"');
@@ -46,9 +46,9 @@ function isEchartsConfig(code: string): boolean {
// 检查是否包含 ECharts 的关键配置字段
return !!(
typeof obj === 'object' &&
obj !== null &&
(obj.xAxis || obj.yAxis || obj.series || obj.title || obj.legend || obj.gauge || obj.pie || obj.bar)
typeof obj === 'object'
&& obj !== null
&& (obj.xAxis || obj.yAxis || obj.series || obj.title || obj.legend || obj.gauge || obj.pie || obj.bar)
);
}
catch {
@@ -63,15 +63,15 @@ function renderEcharts(code: string) {
console.log('[codeXRender] 渲染 echarts代码长度:', code?.length || 0);
return h(EchartsRenderer, {
selfProps: {
code: code,
code,
width: '100%',
height: '600px', // 增加高度
height: '600px', // 增加高度
theme: 'dark',
},
style: {
width: '100%',
maxWidth: '100%'
}
maxWidth: '100%',
},
});
}
@@ -92,13 +92,13 @@ function renderCodeBlock(code: string, language: string) {
* API 格式: { [language]: (props: { raw: { content: string } }) => VNode }
*/
export const codeXRender = {
echarts: (props: { raw: any }) => {
'echarts': (props: { raw: any }) => {
const code = props.raw?.content || '';
console.log('[codeXRender.echarts] 收到代码,长度:', code.length);
return renderEcharts(code);
},
json: (props: { raw: any }) => {
'json': (props: { raw: any }) => {
const code = props.raw?.content || '';
console.log('[codeXRender.json] 收到代码,长度:', code.length);
if (isEchartsConfig(code)) {
@@ -107,7 +107,7 @@ export const codeXRender = {
return renderCodeBlock(code, 'json');
},
javascript: (props: { raw: any }) => {
'javascript': (props: { raw: any }) => {
const code = props.raw?.content || '';
console.log('[codeXRender.javascript] 收到代码,长度:', code.length);
if (isEchartsConfig(code)) {
@@ -116,7 +116,7 @@ export const codeXRender = {
return renderCodeBlock(code, 'javascript');
},
text: (props: { raw: any }) => {
'text': (props: { raw: any }) => {
const code = props.raw?.content || '';
if (isEchartsConfig(code)) {
return renderEcharts(code);

View File

@@ -21,10 +21,12 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -43,15 +45,19 @@ declare module 'vue' {
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']

View File

@@ -26,8 +26,8 @@ export default defineConfig((cnf) => {
},
// 浏览器缓存问题
server: {
host: '0.0.0.0', // 监听所有网络接口
port: 5137, // 端口号
host: '0.0.0.0', // 监听所有网络接口
port: 5137, // 端口号
headers: {
'Cache-Control': 'no-store',
},

View File

@@ -0,0 +1,74 @@
-- 线索中心测试数据生成脚本
-- 执行方式: docker exec -i hzhub-mysql mysql -u root -phzhub123 hzhub --default-character-set=utf8mb4 < lead_test_data.sql
SET NAMES utf8mb4;
SET @id_base = 2059000000000000000;
INSERT INTO crm_lead (
lead_id, customer_code, company_name, contact_name, mobile, wechat,
province, city, source_type, activity_name, referrer_name,
industry, company_scale, store_count, intent_level, ai_score, risk_level,
owner_user_id, lead_status, converted_dealer_id, next_follow_time, remark,
tenant_id, create_by, create_time, update_by, update_time, del_flag
) VALUES
-- 高意向线索(本月新增)
(@id_base+1, 'CUS001', '杭州智汇科技有限公司', '张伟', '13800138001', 'zhangwei001', '浙江', '杭州', 'activity', '2026春季招商会', NULL, 'IT', '50-100人', 15, 'high', 85.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 3 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+2, 'CUS002', '上海创新软件有限公司', '李明', '13900139002', 'liming002', '上海', '上海', 'referral', NULL, '王经理', '软件', '100-200人', 20, 'high', 78.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 5 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+3, 'CUS003', '深圳智能硬件公司', '王芳', '13700137003', 'wangfang003', '广东', '深圳', 'website', NULL, NULL, '电子', '50-100人', 8, 'high', 92.00, 'low', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 2 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+4, 'CUS004', '广州数字科技有限公司', '刘洋', '13600136004', 'liuyang004', '广东', '广州', 'exhibition', '2026科技展会', NULL, '科技', '200-500人', 30, 'high', 88.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 7 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 35 DAY), 1, NOW(), 0),
(@id_base+5, 'CUS005', '北京云计算有限公司', '陈静', '13500135005', 'chenjing005', '北京', '北京', 'wecom', NULL, NULL, '云计算', '100-200人', 12, 'high', 75.00, 'medium', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 40 DAY), 1, NOW(), 0),
-- 中意向线索(本月新增)
(@id_base+6, 'CUS006', '苏州智能制造有限公司', '赵强', '13800138006', 'zhaoqiang006', '江苏', '苏州', 'activity', '2026春季招商会', NULL, '制造', '50-100人', 10, 'medium', 55.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 6 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+7, 'CUS007', '武汉新能源科技有限公司', '孙丽', '13900139007', 'sunli007', '湖北', '武汉', 'website', NULL, NULL, '新能源', '20-50人', 5, 'medium', 48.00, 'low', 3, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+8, 'CUS008', '成都生物科技公司', '周杰', '13700137008', 'zhoujie008', '四川', '成都', 'referral', NULL, '李总', '生物', '100-200人', 18, 'medium', 62.00, 'medium', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 8 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+9, 'CUS009', '南京电商平台有限公司', '吴敏', '13600136009', 'wumin009', '江苏', '南京', 'wecom', NULL, NULL, '电商', '50-100人', 25, 'medium', 58.00, 'low', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 10 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+10, 'CUS010', '天津物流科技公司', '郑华', '13500135010', 'zhenghua010', '天津', '天津', 'other', NULL, NULL, '物流', '20-50人', 6, 'medium', 45.00, 'low', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+11, 'CUS011', '青岛智能家电公司', '黄涛', '13800138011', 'huangtao011', '山东', '青岛', 'exhibition', '2026家电展', NULL, '家电', '200-500人', 40, 'medium', 52.00, 'medium', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 12 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 32 DAY), 1, NOW(), 0),
(@id_base+12, 'CUS012', '厦门数字媒体公司', '林雪', '13900139012', 'linxue012', '福建', '厦门', 'activity', '2026数字营销大会', NULL, '媒体', '50-100人', 8, 'medium', 60.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 9 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 38 DAY), 1, NOW(), 0),
-- 低意向线索(本月新增)
(@id_base+13, 'CUS013', '长沙新材料科技有限公司', '杨帆', '13700137013', 'yangfan013', '湖南', '长沙', 'website', NULL, NULL, '新材料', '10-20人', 3, 'low', 25.00, 'high', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+14, 'CUS014', '郑州智能交通公司', '徐磊', '13600136014', 'xulei014', '河南', '郑州', 'other', NULL, NULL, '交通', '20-50人', 4, 'low', 18.00, 'medium', 3, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+15, 'CUS015', '西安航空航天科技有限公司', '朱红', '13500135015', 'zhuhong015', '陕西', '西安', 'referral', NULL, '赵总', '航空', '100-200人', 15, 'low', 32.00, 'low', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+16, 'CUS016', '沈阳自动化设备有限公司', '高峰', '13800138016', 'gaofeng016', '辽宁', '沈阳', 'exhibition', '2026工业展', NULL, '自动化', '50-100人', 10, 'low', 28.00, 'medium', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 15 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 45 DAY), 1, NOW(), 0),
(@id_base+17, 'CUS017', '大连海洋科技公司', '梁艳', '13900139017', 'liangyan017', '辽宁', '大连', 'activity', '2026海洋经济论坛', NULL, '海洋', '20-50人', 5, 'low', 22.00, 'high', 3, 'following', NULL, NULL, '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 50 DAY), 1, NOW(), 0),
-- 已转化线索(本月)
(@id_base+18, 'CUS018', '杭州电商科技有限公司', '谢军', '13700137018', 'xiejun018', '浙江', '杭州', 'activity', '2026春季招商会', NULL, '电商', '50-100人', 20, 'high', 95.00, 'low', 1, 'converted', 1, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+19, 'CUS019', '上海智能家居有限公司', '唐娜', '13600136019', 'tangna019', '上海', '上海', 'referral', NULL, '陈总', '智能家居', '100-200人', 25, 'high', 88.00, 'low', 3, 'converted', 2, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
-- 已转化线索(上月)
(@id_base+20, 'CUS020', '深圳物联网科技有限公司', '曹飞', '13500135020', 'caofei020', '广东', '深圳', 'wecom', NULL, NULL, '物联网', '200-500人', 35, 'high', 90.00, 'low', 1, 'converted', 3, NULL, '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 25 DAY), 1, NOW(), 0),
(@id_base+21, 'CUS021', '北京人工智能有限公司', '袁青', '13800138021', 'yuanqing21', '北京', '北京', 'website', NULL, NULL, 'AI', '100-200人', 18, 'high', 82.00, 'low', 3, 'converted', 4, NULL, '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 30 DAY), 1, NOW(), 0),
-- 更多新线索(本月)
(@id_base+22, 'CUS022', '合肥量子科技有限公司', '邓鑫', '13900139022', 'dengxin022', '安徽', '合肥', 'website', NULL, NULL, '量子', '10-20人', 2, 'high', 72.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 1 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+23, 'CUS023', '福州智能制造有限公司', '许婷', '13700137023', 'xuting023', '福建', '福州', 'activity', '2026春季招商会', NULL, '制造', '50-100人', 12, 'medium', 50.00, 'low', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+24, 'CUS024', '昆明生物医药公司', '贺强', '13600136024', 'heqiang024', '云南', '昆明', 'referral', NULL, '张经理', '生物', '20-50人', 6, 'medium', 55.00, 'medium', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 5 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+25, 'CUS025', '贵阳大数据科技有限公司', '蒋萍', '13500135025', 'jiangping25', '贵州', '贵阳', 'wecom', NULL, NULL, '大数据', '100-200人', 22, 'high', 68.00, 'low', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 2 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+26, 'CUS026', '哈尔滨冰雪科技有限公司', '范明', '13800138026', 'fanming026', '黑龙江', '哈尔滨', 'exhibition', '2026冰雪经济展', NULL, '旅游', '50-100人', 8, 'medium', 45.00, 'medium', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 3 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 15 DAY), 1, NOW(), 0),
(@id_base+27, 'CUS027', '长春汽车电子公司', '钱波', '13900139027', 'qianbo027', '吉林', '长春', 'erp', NULL, NULL, '汽车', '200-500人', 50, 'high', 78.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 6 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 20 DAY), 1, NOW(), 0),
(@id_base+28, 'CUS028', '南昌智慧农业有限公司', '魏兰', '13700137028', 'weilan028', '江西', '南昌', 'activity', '2026智慧农业论坛', NULL, '农业', '20-50人', 10, 'medium', 52.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 7 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 25 DAY), 1, NOW(), 0),
(@id_base+29, 'CUS029', '太原能源科技公司', '蔡浩', '13600136029', 'caiha029', '山西', '太原', 'website', NULL, NULL, '能源', '100-200人', 15, 'high', 85.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 30 DAY), 1, NOW(), 0),
(@id_base+30, 'CUS030', '呼和浩特乳业科技有限公司', '贾涛', '13500135030', 'jiatao030', '内蒙古', '呼和浩特', 'referral', NULL, '李总', '食品', '200-500人', 60, 'high', 92.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 8 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 35 DAY), 1, NOW(), 0),
-- 作废线索
(@id_base+31, 'CUS031', '银川新材料有限公司', '邹华', '13800138031', 'zouhua031', '宁夏', '银川', 'website', NULL, NULL, '材料', '10-20人', 3, 'low', 15.00, 'high', 1, 'invalid', NULL, NULL, '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 60 DAY), 1, NOW(), 0),
(@id_base+32, 'CUS032', '乌鲁木齐能源公司', '尹明', '13900139032', 'yinming032', '新疆', '乌鲁木齐', 'other', NULL, NULL, '能源', '50-100人', 8, 'low', 20.00, 'high', 3, 'invalid', NULL, NULL, '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 55 DAY), 1, NOW(), 0),
-- 更多高意向线索
(@id_base+33, 'CUS033', '温州服装科技有限公司', '潘杰', '13700137033', 'panjie033', '浙江', '温州', 'activity', '2026春季招商会', NULL, '服装', '50-100人', 15, 'high', 75.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 2 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+34, 'CUS034', '宁波港口科技公司', '罗敏', '13600136034', 'luomin034', '浙江', '宁波', 'exhibition', '2026物流展', NULL, '物流', '100-200人', 25, 'high', 80.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 5 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 20 DAY), 1, NOW(), 0),
(@id_base+35, 'CUS035', '无锡物联网有限公司', '曾涛', '13500135035', 'zengtao035', '江苏', '无锡', 'wecom', NULL, NULL, '物联网', '200-500人', 40, 'high', 88.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 10 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 28 DAY), 1, NOW(), 0),
(@id_base+36, 'CUS036', '常州机械科技有限公司', '石芳', '13800138036', 'shifang036', '江苏', '常州', 'referral', NULL, '王经理', '机械', '50-100人', 12, 'medium', 55.00, 'medium', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 3 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+37, 'CUS037', '镇江化工科技有限公司', '雷明', '13900139037', 'leiming37', '江苏', '镇江', 'website', NULL, NULL, '化工', '100-200人', 18, 'medium', 48.00, 'medium', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+38, 'CUS038', '扬州文化旅游有限公司', '韩雪', '13700137038', 'hanxue038', '江苏', '扬州', 'activity', '2026文旅论坛', NULL, '文旅', '20-50人', 5, 'medium', 42.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 8 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 22 DAY), 1, NOW(), 0),
(@id_base+39, 'CUS039', '泰州医药科技有限公司', '秦强', '13600136039', 'qinqiang39', '江苏', '泰州', 'other', NULL, NULL, '医药', '10-20人', 2, 'low', 28.00, 'medium', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+40, 'CUS040', '盐城新能源有限公司', '龙艳', '13500135040', 'longyan040', '江苏', '盐城', 'website', NULL, NULL, '新能源', '50-100人', 10, 'low', 35.00, 'low', 3, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+41, 'CUS041', '嘉兴纺织科技有限公司', '顾军', '13800138041', 'gujun041', '浙江', '嘉兴', 'activity', '2026春季招商会', NULL, '纺织', '100-200人', 30, 'high', 72.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 1 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+42, 'CUS042', '湖州环保科技有限公司', '侯娜', '13900139042', 'houna042', '浙江', '湖州', 'referral', NULL, '陈总', '环保', '20-50人', 8, 'medium', 58.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 18 DAY), 1, NOW(), 0),
(@id_base+43, 'CUS043', '绍兴酒业科技有限公司', '邵飞', '13700137043', 'shaofei043', '浙江', '绍兴', 'erp', NULL, NULL, '酒类', '50-100人', 12, 'high', 82.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 6 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 25 DAY), 1, NOW(), 0),
(@id_base+44, 'CUS044', '金华五金科技有限公司', '钱青', '13600136044', 'qianqing44', '浙江', '金华', 'wecom', NULL, NULL, '五金', '100-200人', 22, 'medium', 50.00, 'medium', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 3 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+45, 'CUS045', '衢州建材科技有限公司', '孙鑫', '13500135045', 'sunxin045', '浙江', '衢州', 'website', NULL, NULL, '建材', '20-50人', 6, 'low', 25.00, 'high', 1, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+46, 'CUS046', '丽水生态科技有限公司', '李婷', '13800138046', 'liting046', '浙江', '丽水', 'activity', '2026生态经济论坛', NULL, '生态', '10-20人', 4, 'medium', 45.00, 'low', 3, 'following', NULL, DATE_ADD(NOW(), INTERVAL 5 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 15 DAY), 1, NOW(), 0),
(@id_base+47, 'CUS047', '台州汽摩科技有限公司', '王强', '13900139047', 'wangqiang47', '浙江', '台州', 'exhibition', '2026汽摩展', NULL, '汽摩', '200-500人', 45, 'high', 78.00, 'low', 1, 'following', NULL, DATE_ADD(NOW(), INTERVAL 8 DAY), '测试数据', '000001', 1, DATE_SUB(NOW(), INTERVAL 22 DAY), 1, NOW(), 0),
(@id_base+48, 'CUS048', '舟山海洋科技有限公司', '赵萍', '13700137048', 'zhaoping48', '浙江', '舟山', 'referral', NULL, '周总', '海洋', '50-100人', 15, 'high', 85.00, 'low', 3, 'new', NULL, DATE_ADD(NOW(), INTERVAL 2 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+49, 'CUS049', '烟台苹果科技有限公司', '陈明', '13600136049', 'chenming49', '山东', '烟台', 'website', NULL, NULL, '农业', '20-50人', 8, 'medium', 52.00, 'low', 1, 'new', NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), '测试数据', '000001', 1, NOW(), 1, NOW(), 0),
(@id_base+50, 'CUS050', '潍坊风筝科技有限公司', '杨波', '13500135050', 'yangbo050', '山东', '潍坊', 'activity', '2026风筝节招商', NULL, '文创', '10-20人', 5, 'low', 30.00, 'medium', 3, 'new', NULL, NULL, '测试数据', '000001', 1, NOW(), 1, NOW(), 0);
SELECT '=== 测试数据统计 ===' as info;
SELECT lead_status, intent_level, COUNT(*) as count FROM crm_lead WHERE remark='测试数据' GROUP BY lead_status, intent_level ORDER BY lead_status, intent_level;

View File

@@ -11,6 +11,7 @@ import org.hzhub.common.mybatis.core.page.TableDataInfo;
import org.hzhub.common.web.core.BaseController;
import org.hzhub.crm.domain.bo.CrmLeadBo;
import org.hzhub.crm.domain.bo.CrmLeadConvertBo;
import org.hzhub.crm.domain.vo.CrmLeadStatsVo;
import org.hzhub.crm.domain.vo.CrmLeadVo;
import org.hzhub.crm.service.ICrmLeadService;
import org.springframework.validation.annotation.Validated;
@@ -43,12 +44,20 @@ public class CrmLeadController extends BaseController {
return leadService.selectPageLeadList(lead, pageQuery);
}
/**
* 获取线索统计数据
*/
@GetMapping("/stats")
public R<CrmLeadStatsVo> getStats() {
return R.ok(leadService.getLeadStats());
}
/**
* 获取线索详情
*
* @param leadId 线索ID
*/
@GetMapping("/{leadId}")
@GetMapping("/detail/{leadId}")
public R<CrmLeadVo> getInfo(@PathVariable Long leadId) {
return R.ok(leadService.selectLeadById(leadId));
}
@@ -112,4 +121,26 @@ public class CrmLeadController extends BaseController {
public R<Void> convert(@Validated @RequestBody CrmLeadConvertBo convert) {
return toAjax(leadService.convertToDealer(convert));
}
/**
* 作废线索
*
* @param leadId 线索ID
*/
@Log(title = "线索作废", businessType = BusinessType.UPDATE)
@PutMapping("/invalidate/{leadId}")
public R<Void> invalidate(@PathVariable Long leadId) {
return toAjax(leadService.invalidateLead(leadId));
}
/**
* 恢复线索
*
* @param leadId 线索ID
*/
@Log(title = "线索恢复", businessType = BusinessType.UPDATE)
@PutMapping("/restore/{leadId}")
public R<Void> restore(@PathVariable Long leadId) {
return toAjax(leadService.restoreLead(leadId));
}
}

View File

@@ -0,0 +1,64 @@
package org.hzhub.crm.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* CRM线索统计 VO
*
* @author hzhub
*/
@Data
public class CrmLeadStatsVo {
/**
* 线索总数
*/
private Long totalCount;
/**
* 高意向线索数量
*/
private Long highIntentCount;
/**
* 本月新增线索数量
*/
private Long monthlyNewCount;
/**
* 本月新增线索数量(上月,用于环比计算)
*/
private Long lastMonthNewCount;
/**
* 已转化线索数量
*/
private Long convertedCount;
/**
* 转化率(百分比)
*/
private BigDecimal conversionRate;
/**
* 本月转化数量
*/
private Long monthlyConvertedCount;
/**
* 上月转化数量
*/
private Long lastMonthConvertedCount;
/**
* 本月高意向数量
*/
private Long monthlyHighIntentCount;
/**
* 上月高意向数量
*/
private Long lastMonthHighIntentCount;
}

View File

@@ -1,5 +1,7 @@
package org.hzhub.crm.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.hzhub.common.mybatis.core.mapper.BaseMapperPlus;
import org.hzhub.crm.domain.CrmLead;
import org.hzhub.crm.domain.vo.CrmLeadVo;
@@ -11,4 +13,39 @@ import org.hzhub.crm.domain.vo.CrmLeadVo;
*/
public interface CrmLeadMapper extends BaseMapperPlus<CrmLead, CrmLeadVo> {
/**
* 统计线索总数
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId}")
Long countTotal(@Param("tenantId") String tenantId);
/**
* 统计高意向线索数量
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId} AND (intent_level = 'high' OR ai_score >= 60)")
Long countHighIntent(@Param("tenantId") String tenantId);
/**
* 统计本月新增线索数量
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId} AND YEAR(create_time) = #{year} AND MONTH(create_time) = #{month}")
Long countMonthlyNew(@Param("tenantId") String tenantId, @Param("year") int year, @Param("month") int month);
/**
* 统计已转化线索数量
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId} AND lead_status = 'converted'")
Long countConverted(@Param("tenantId") String tenantId);
/**
* 统计本月转化线索数量
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId} AND lead_status = 'converted' AND YEAR(create_time) = #{year} AND MONTH(create_time) = #{month}")
Long countMonthlyConverted(@Param("tenantId") String tenantId, @Param("year") int year, @Param("month") int month);
/**
* 统计本月高意向线索数量
*/
@Select("SELECT COUNT(*) FROM crm_lead WHERE del_flag = 0 AND tenant_id = #{tenantId} AND (intent_level = 'high' OR ai_score >= 60) AND YEAR(create_time) = #{year} AND MONTH(create_time) = #{month}")
Long countMonthlyHighIntent(@Param("tenantId") String tenantId, @Param("year") int year, @Param("month") int month);
}

View File

@@ -6,6 +6,7 @@ import org.hzhub.crm.domain.bo.CrmLeadBo;
import org.hzhub.crm.domain.bo.CrmLeadConvertBo;
import org.hzhub.crm.domain.bo.CrmLeadFollowBo;
import org.hzhub.crm.domain.vo.CrmLeadFollowVo;
import org.hzhub.crm.domain.vo.CrmLeadStatsVo;
import org.hzhub.crm.domain.vo.CrmLeadVo;
import java.util.List;
@@ -34,6 +35,13 @@ public interface ICrmLeadService {
*/
CrmLeadVo selectLeadById(Long leadId);
/**
* 获取线索统计数据
*
* @return 线索统计VO
*/
CrmLeadStatsVo getLeadStats();
/**
* 新增线索
*
@@ -98,4 +106,20 @@ public interface ICrmLeadService {
* @return 结果
*/
int convertToDealer(CrmLeadConvertBo convert);
/**
* 作废线索
*
* @param leadId 线索ID
* @return 结果
*/
int invalidateLead(Long leadId);
/**
* 恢复线索
*
* @param leadId 线索ID
* @return 结果
*/
int restoreLead(Long leadId);
}

View File

@@ -0,0 +1,20 @@
package org.hzhub.system.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 配置
* 用于调用ERP服务
*
* @author hzhub
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@@ -50,10 +50,10 @@ cd /data/hzhub/hzhub-portal-employee
./start.sh
# 启动经销商门户
echo ""
echo "7⃣ 启动经销商门户 (hzhub-portal-dealer)..."
cd /data/hzhub/hzhub-portal-dealer
./start.sh
#echo ""
#echo "7⃣ 启动经销商门户 (hzhub-portal-dealer)..."
#cd /data/hzhub/hzhub-portal-dealer
#./start.sh
echo ""
echo "========================================="
@@ -67,7 +67,7 @@ echo " 系统服务: http://localhost:8083 (认证/系统/工作流/代码生
echo " ERP服务: http://localhost:8082 (直连)"
echo " 管理后台: http://localhost:5666"
echo " 员工门户: http://localhost:5137"
echo " 经销商门户: http://localhost:5138"
#echo " 经销商门户: http://localhost:5138"
echo ""
echo "查看服务状态:"
echo " ./status-all.sh"

View File

@@ -6,10 +6,10 @@ echo " 停止所有 HZHub 服务"
echo "========================================="
# 停止前端门户(先停前端,避免后端关闭时前端持续重连)
echo ""
echo "1⃣ 停止经销商门户 (hzhub-portal-dealer)..."
cd /data/hzhub/hzhub-portal-dealer
./stop.sh 2>/dev/null || { echo "脚本不存在,按端口清理..."; fuser -k 5138/tcp 2>/dev/null; }
#echo ""
#echo "1⃣ 停止经销商门户 (hzhub-portal-dealer)..."
#cd /data/hzhub/hzhub-portal-dealer
#./stop.sh 2>/dev/null || { echo "脚本不存在,按端口清理..."; fuser -k 5138/tcp 2>/dev/null; }
echo ""
echo "2⃣ 停止员工门户 (hzhub-portal-employee)..."