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:
@@ -1 +1 @@
|
||||
3357568
|
||||
3183821
|
||||
|
||||
@@ -1 +1 @@
|
||||
3999223
|
||||
3182697
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
3422048
|
||||
3183908
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; // 新增公司名称字段
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserProfile, UpdatePasswordParam } from './types';
|
||||
import type { UpdatePasswordParam, UserProfile } from './types';
|
||||
|
||||
import { get, put, request } from '@/utils/request';
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 分页查询审批列表 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 系统主题
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 空闲超时自动锁屏
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { createGlobalState } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 全局租户ID状态
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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数据分析模块将提供销售分析、客户分析、经销商分析、
|
||||
供应链分析等可视化报表,支持自定义报表和数据钻取。
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 客户列表表格 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
供应链管理模块将提供采购管理、库存管理、订单管理、
|
||||
物流跟踪、供应商管理等功能,支持全流程供应链协同。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useKeepAliveStore = defineStore(
|
||||
'keep-alive',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
6
hzhub-portal-employee/types/components.d.ts
vendored
6
hzhub-portal-employee/types/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
74
hzhub-system/sql/lead_test_data.sql
Normal file
74
hzhub-system/sql/lead_test_data.sql
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
10
start-all.sh
10
start-all.sh
@@ -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"
|
||||
|
||||
@@ -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)..."
|
||||
|
||||
Reference in New Issue
Block a user