feat: 添加ERP服务和系统服务,完善员工门户功能
## 新增服务模块 ### 1. ERP服务 (hzhub-erp) - 新增独立的ERP数据适配服务 - 支持SQL Server 2008 R2数据源 - 提供动态API配置管理系统 - 包含客户管理、销售数据等业务接口 ### 2. 系统服务 (hzhub-system) - 新增独立的系统管理服务 - 用户、角色、权限、部门、菜单管理 - 租户管理、操作日志、在线用户监控 - 工作流引擎(warm-flow)集成 - 企业微信审批同步功能 ### 3. API网关 (hzhub-gateway) - 新增Spring Cloud Gateway网关服务 - JWT认证、路由分发、限流熔断 - XSS防护、请求日志记录 - 统一入口端口8080 ## 后台管理功能增强 ### ERP动态API管理 - 新增动态API配置管理界面 - API测试、文档预览、统计监控 - 错误日志查看、缓存管理 - 从数据库表自动导入API配置 ### 系统管理增强 - 企业微信配置管理 - 企业微信审批同步配置 - 部门和用户管理优化 ## 员工门户功能完善 ### 业务页面 - 审批中心:工作流审批、待办任务 - CRM管理:客户关系管理 - 经销商管理:经销商数据展示 - 供应链管理:采购、库存、销售 - BI报表:数据可视化分析 - ERP数据探索:SQL Server数据查询 ### 个人中心 - 基本设置:个人信息管理 - 安全设置:密码修改、登录日志 - 锁屏功能:自动锁屏、手动锁屏 ### 其他功能 - 标签页管理:多标签页导航 - 页面缓存:keepAlive缓存机制 - 会话超时:自动检测并提示 ## 经销商门户 ### 页面路由 - 新增经销商管理页面路由 - AI聊天界面完善 ## 文档更新 - ERP API数据库初始化指南 - ERP API前端完整实现文档 - ERP API测试和验证指南 - Gateway路由迁移计划 - 项目配置文档更新 ## 部署脚本 - 统一启动/停止/重启脚本 - Docker Compose配置优化 - Nginx配置文件更新 ## 技术栈 - 后端: Spring Boot 3.5.8, Java 17 - 前端: Vue 3, TypeScript, Element Plus, Vben Admin - 工作流: warm-flow 1.8.2 - 网关: Spring Cloud Gateway - 数据库: MySQL 8.0, SQL Server 2008 R2 - 缓存: Redis 7 - 向量库: Weaviate 1.25.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { UserProfile, UpdatePasswordParam } from './types';
|
||||
|
||||
import { get, put, post } from '@/utils/request';
|
||||
import { get, put, request } from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 用户个人主页信息
|
||||
@@ -16,7 +16,7 @@ export function userProfile() {
|
||||
* @returns void
|
||||
*/
|
||||
export function userProfileUpdate(data: any) {
|
||||
return put<void>('/system/user/profile', { body: data }).json();
|
||||
return put<void>('/system/user/profile', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ export function userProfileUpdate(data: any) {
|
||||
* @returns void
|
||||
*/
|
||||
export function userUpdatePassword(data: UpdatePasswordParam) {
|
||||
return put<void>('/system/user/profile/updatePwd', { body: data }).json();
|
||||
return put<void>('/system/user/profile/updatePwd', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,5 +36,5 @@ export function userUpdatePassword(data: UpdatePasswordParam) {
|
||||
export function userUpdateAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatarfile', file);
|
||||
return post<void>('/system/user/profile/avatar', { body: formData }).json();
|
||||
return request.post<void>('/system/user/profile/avatar', formData).json();
|
||||
}
|
||||
@@ -43,12 +43,11 @@ export interface User {
|
||||
loginDate: string;
|
||||
remark: string;
|
||||
createTime: string;
|
||||
dept: Dept;
|
||||
deptName?: string;
|
||||
roles: Role[];
|
||||
roleIds?: string[];
|
||||
postIds?: string[];
|
||||
roleId: number;
|
||||
deptName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
63
hzhub-portal-employee/src/api/wecom/index.ts
Normal file
63
hzhub-portal-employee/src/api/wecom/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
WecomApprovalVo,
|
||||
WecomApprovalDetail,
|
||||
ApprovalStats,
|
||||
WecomTemplate,
|
||||
ApprovalListParams,
|
||||
SubmitApprovalBo,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
WecomApprovalVo,
|
||||
WecomApprovalDetail,
|
||||
ApprovalStats,
|
||||
WecomTemplate,
|
||||
ApprovalListParams,
|
||||
SubmitApprovalBo,
|
||||
};
|
||||
|
||||
/** 分页查询审批列表 */
|
||||
export function getApprovalList(params: ApprovalListParams) {
|
||||
return get<{ rows: WecomApprovalVo[]; total: number }>('/wecom/approval/list', params).json();
|
||||
}
|
||||
|
||||
/** 获取审批详情(返回原始JSON字符串) */
|
||||
export function getApprovalDetail(spNo: string) {
|
||||
return get<string>(`/wecom/approval/${spNo}`).json();
|
||||
}
|
||||
|
||||
/** 发起审批申请 */
|
||||
export function submitApproval(data: SubmitApprovalBo) {
|
||||
return post<string>('/wecom/approval/submit', data).json();
|
||||
}
|
||||
|
||||
/** 触发全量同步(管理员) */
|
||||
export function syncApprovals(daysBack = 30) {
|
||||
return post('/wecom/approval/sync/full', {}, { params: { daysBack } }).json();
|
||||
}
|
||||
|
||||
/** 同步当前用户审批数据(近1天) */
|
||||
export function syncCurrentUserApprovals() {
|
||||
return post<string>('/wecom/approval/sync/current', {}).json();
|
||||
}
|
||||
|
||||
/** 获取模板列表 */
|
||||
export function getTemplateList() {
|
||||
return get<{ templateId: string; templateName: string; syncTime: string }[]>('/wecom/approval/templates').json();
|
||||
}
|
||||
|
||||
/** 获取统计数据(全局) */
|
||||
export function getApprovalStats() {
|
||||
return get<ApprovalStats>('/wecom/approval/stats').json();
|
||||
}
|
||||
|
||||
/** 获取当前用户统计数据 */
|
||||
export function getUserApprovalStats() {
|
||||
return get<ApprovalStats>('/wecom/approval/stats/user').json();
|
||||
}
|
||||
|
||||
/** 导出审批数据 */
|
||||
export function exportApprovals(status?: string) {
|
||||
return post('/wecom/approval/export', { status }).json();
|
||||
}
|
||||
91
hzhub-portal-employee/src/api/wecom/types.ts
Normal file
91
hzhub-portal-employee/src/api/wecom/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** 企业微信审批单 */
|
||||
export interface WecomApprovalVo {
|
||||
spNo: string;
|
||||
spName: string;
|
||||
spStatus: number;
|
||||
spStatusText: string;
|
||||
templateId: string;
|
||||
templateName?: string; // 审批类型名称
|
||||
applyTime: number;
|
||||
applyerUserid: string;
|
||||
applyerParty: string;
|
||||
summaryInfo: string;
|
||||
amount: number;
|
||||
syncTime: string;
|
||||
}
|
||||
|
||||
/** 审批详情(原始JSON字符串解析后) */
|
||||
export interface WecomApprovalDetail {
|
||||
sp_no: string;
|
||||
sp_name: string;
|
||||
sp_status: number;
|
||||
template_id: string;
|
||||
apply_time: number;
|
||||
applyer: { userid: string; party: string };
|
||||
apply_data: {
|
||||
contents: Array<{
|
||||
control: string;
|
||||
id: string;
|
||||
title: Array<{ text: string; lang: string }>;
|
||||
value: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
nodes: Array<{
|
||||
sp_status: number;
|
||||
apv_rel: number;
|
||||
sub_node: Array<{
|
||||
sp_status: number;
|
||||
approver: { userid: string };
|
||||
speech: string;
|
||||
sp_time: number;
|
||||
}>;
|
||||
}>;
|
||||
comments: Array<{
|
||||
commentUserInfo: { userid: string };
|
||||
commentContent: string;
|
||||
commentTime: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 统计数据 */
|
||||
export interface ApprovalStats {
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
revoked: number;
|
||||
total: number;
|
||||
mine?: number;
|
||||
}
|
||||
|
||||
/** 审批模板 */
|
||||
export interface WecomTemplate {
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
syncTime: string;
|
||||
}
|
||||
|
||||
/** 提交审批参数 */
|
||||
export interface SubmitApprovalBo {
|
||||
creatorUserid: string;
|
||||
templateId: string;
|
||||
useTemplateApprover?: number;
|
||||
approver?: Array<{ attr: number; userid: string[] }>;
|
||||
notifyer?: string[];
|
||||
notifyType?: number;
|
||||
contents: Array<{
|
||||
control: string;
|
||||
id: string;
|
||||
title: Array<{ text: string; lang: string }>;
|
||||
value: Record<string, unknown>;
|
||||
}>;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/** 分页查询参数 */
|
||||
export interface ApprovalListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
type?: string;
|
||||
status?: string | number;
|
||||
keyword?: string;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
<!-- 账号密码登录表单 -->
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import type { LoginDTO } from '@/api/auth/types';
|
||||
import { reactive, ref } from 'vue';
|
||||
import type { LoginDTO, TenantResp } from '@/api/auth/types';
|
||||
import { reactive, ref, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { login } from '@/api';
|
||||
import { login, tenantList } from '@/api';
|
||||
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<{
|
||||
@@ -18,14 +19,23 @@ const userStore = useUserStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const loginFromStore = useLoginFormStore();
|
||||
|
||||
// 使用全局租户ID状态
|
||||
const { loginTenantId } = useLoginTenantId();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
// 租户信息
|
||||
const tenantInfo = ref<TenantResp>({
|
||||
tenantEnabled: false,
|
||||
voList: [],
|
||||
});
|
||||
|
||||
const formModel = reactive<LoginDTO>({
|
||||
username: '',
|
||||
password: '',
|
||||
clientId: import.meta.env.VITE_CLIENT_ID,
|
||||
grantType: 'password',
|
||||
tenantId: '000000',
|
||||
tenantId: loginTenantId.value, // 使用全局状态
|
||||
uuid: 'a5705def96be468f80e4b8bde3127c31',
|
||||
});
|
||||
|
||||
@@ -34,6 +44,45 @@ const rules = reactive<FormRules<LoginDTO>>({
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
// 监听表单中的租户ID变化,同步到全局状态(添加防抖,避免循环触发)
|
||||
watch(() => formModel.tenantId, (newTenantId, oldTenantId) => {
|
||||
// 仅在值真正改变时更新全局状态
|
||||
if (newTenantId !== oldTenantId && newTenantId !== loginTenantId.value) {
|
||||
console.log('租户ID变化:', oldTenantId, '->', newTenantId);
|
||||
loginTenantId.value = newTenantId || '000000';
|
||||
console.log('全局状态已同步:', loginTenantId.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 加载租户列表
|
||||
async function loadTenant() {
|
||||
try {
|
||||
console.log('开始加载租户列表,当前全局租户ID:', loginTenantId.value);
|
||||
const resp = await tenantList();
|
||||
tenantInfo.value = resp;
|
||||
console.log('租户列表加载完成,启用多租户:', resp.tenantEnabled, '租户数量:', resp.voList.length);
|
||||
|
||||
// 仅在全局租户ID为默认值时才设置第一个租户
|
||||
if (resp.tenantEnabled && resp.voList.length > 0 && loginTenantId.value === '000000') {
|
||||
const firstTenantId = resp.voList[0].tenantId;
|
||||
console.log('全局租户ID为默认值,设置第一个租户:', firstTenantId);
|
||||
loginTenantId.value = firstTenantId;
|
||||
formModel.tenantId = firstTenantId;
|
||||
} else {
|
||||
// 如果全局状态已有值,同步到表单
|
||||
console.log('使用全局租户ID:', loginTenantId.value);
|
||||
formModel.tenantId = loginTenantId.value;
|
||||
}
|
||||
console.log('最终表单租户ID:', formModel.tenantId);
|
||||
} catch (error) {
|
||||
console.error('加载租户列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenant();
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -99,6 +148,18 @@ async function handleSubmit() {
|
||||
style="width: 230px"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 租户选择(仅当启用多租户时显示) -->
|
||||
<el-form-item v-if="tenantInfo.tenantEnabled" prop="tenantId">
|
||||
<el-select v-model="formModel.tenantId" placeholder="请选择租户" style="width: 100%">
|
||||
<el-option
|
||||
v-for="tenant in tenantInfo.voList"
|
||||
:key="tenant.tenantId"
|
||||
:label="tenant.companyName"
|
||||
:value="tenant.tenantId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="formModel.username" placeholder="请输入用户名">
|
||||
<template #prefix>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { useLockScreenStore } from '@/stores/modules/lockScreen';
|
||||
import { INACTIVITY_TIMEOUT, INACTIVITY_WARNING } from '@/config';
|
||||
|
||||
/**
|
||||
* 空闲超时自动退出登录
|
||||
* 监听用户活动(鼠标、键盘、滚动、触摸),超过设定时间无操作则弹出确认框,确认后仍无操作则自动退出
|
||||
* 空闲超时自动锁屏
|
||||
* 监听用户活动(鼠标、键盘、滚动、触摸),超过设定时间无操作则弹出警告,
|
||||
* 确认后仍无操作则自动锁屏,而非退出登录。
|
||||
*/
|
||||
export function useInactivityTimer() {
|
||||
const userStore = useUserStore();
|
||||
const lockStore = useLockScreenStore();
|
||||
const timer = ref<ReturnType<typeof setTimeout>>();
|
||||
const warningTimer = ref<ReturnType<typeof setTimeout>>();
|
||||
let warningDialog: ReturnType<typeof ElMessageBox.alert> | null = null;
|
||||
@@ -19,27 +22,23 @@ export function useInactivityTimer() {
|
||||
clearTimeout(timer.value);
|
||||
clearTimeout(warningTimer.value);
|
||||
|
||||
// 如果警告框还在,关闭它并重新开始计时
|
||||
if (warningDialog) {
|
||||
warningDialog.close();
|
||||
warningDialog = null;
|
||||
}
|
||||
|
||||
// 设置主超时定时器
|
||||
timer.value = setTimeout(showWarning, INACTIVITY_TIMEOUT - INACTIVITY_WARNING);
|
||||
}
|
||||
|
||||
function showWarning() {
|
||||
// 设置警告倒计时
|
||||
warningTimer.value = setTimeout(doLogout, INACTIVITY_WARNING);
|
||||
warningTimer.value = setTimeout(doLock, INACTIVITY_WARNING);
|
||||
|
||||
// 弹出确认对话框
|
||||
warningDialog = ElMessageBox.confirm(
|
||||
`您已 ${Math.round(INACTIVITY_TIMEOUT / 60000)} 分钟没有操作,系统将在 ${Math.round(INACTIVITY_WARNING / 1000)} 秒后自动退出登录。`,
|
||||
`您已 ${Math.round(INACTIVITY_TIMEOUT / 60000)} 分钟没有操作,系统将在 ${Math.round(INACTIVITY_WARNING / 1000)} 秒后自动锁屏。`,
|
||||
'空闲超时提醒',
|
||||
{
|
||||
confirmButtonText: '继续在线',
|
||||
cancelButtonText: '立即退出',
|
||||
cancelButtonText: '立即锁屏',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--primary',
|
||||
cancelButtonClass: 'el-button--info',
|
||||
@@ -50,32 +49,31 @@ export function useInactivityTimer() {
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
// 用户点击"继续在线",重置定时器
|
||||
warningDialog = null;
|
||||
clearTimeout(warningTimer.value);
|
||||
resetTimer();
|
||||
})
|
||||
.catch(() => {
|
||||
// 用户点击"立即退出"
|
||||
warningDialog = null;
|
||||
doLogout();
|
||||
doLock();
|
||||
});
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
function doLock() {
|
||||
warningDialog = null;
|
||||
clearTimeout(warningTimer.value);
|
||||
userStore.logout();
|
||||
lockStore.lock();
|
||||
}
|
||||
|
||||
function handleActivity() {
|
||||
if (userStore.token) {
|
||||
// 锁屏状态下不响应活动重置
|
||||
if (userStore.token && !lockStore.isLocked) {
|
||||
resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.token) {
|
||||
if (userStore.token && !lockStore.isLocked) {
|
||||
resetTimer();
|
||||
}
|
||||
events.forEach((event) => {
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
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 { useDesignStore } from '@/stores';
|
||||
import { useInactivityTimer } from '@/hooks/useInactivityTimer';
|
||||
import { useLockScreenStore } from '@/stores/modules/lockScreen';
|
||||
|
||||
const designStore = useDesignStore();
|
||||
const lockStore = useLockScreenStore();
|
||||
|
||||
// 初始化标签页管理
|
||||
const { handleClose, handleUnpin } = useTabbar();
|
||||
|
||||
// 初始化空闲超时自动退出
|
||||
// 初始化空闲超时自动锁屏
|
||||
useInactivityTimer();
|
||||
</script>
|
||||
|
||||
@@ -40,6 +43,9 @@ useInactivityTimer();
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 锁屏 -->
|
||||
<LockScreen v-if="lockStore.isLocked" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ const menuGroups = ref<MenuGroup[]>([
|
||||
{
|
||||
label: '业务管理',
|
||||
items: [
|
||||
{ id: 'approval', name: '审批中心', icon: 'Stamp', route: '/approval', badge: 5 },
|
||||
{ id: 'approval', name: '审批中心', icon: 'Stamp', route: '/approval' },
|
||||
{ id: 'dealer', name: '经销商管理', icon: 'Shop', route: '/dealer' },
|
||||
{ id: 'crm', name: '销售CRM', icon: 'TrendCharts', route: '/crm', badge: 3 },
|
||||
{ id: 'crm', name: '销售CRM', icon: 'TrendCharts', route: '/crm' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import Popover from '@/components/Popover/index.vue';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { useLockScreenStore } from '@/stores/modules/lockScreen';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const lockStore = useLockScreenStore();
|
||||
|
||||
/* 弹出面板 开始 */
|
||||
const popoverStyle = ref({
|
||||
@@ -17,6 +19,11 @@ const popoverRef = ref();
|
||||
|
||||
// 弹出面板内容
|
||||
const popoverList = ref([
|
||||
{
|
||||
key: '1',
|
||||
title: '离开锁屏',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
title: '个人中心',
|
||||
@@ -36,6 +43,10 @@ const popoverList = ref([
|
||||
// 点击
|
||||
function handleClick(item: any) {
|
||||
switch (item.key) {
|
||||
case '1':
|
||||
popoverRef.value?.hide?.();
|
||||
lockStore.lock();
|
||||
break;
|
||||
case '2':
|
||||
// 跳转到个人中心
|
||||
popoverRef.value?.hide?.();
|
||||
|
||||
@@ -27,7 +27,7 @@ const searchKeyword = ref('');
|
||||
clearable
|
||||
/>
|
||||
<el-tooltip content="消息通知" placement="bottom">
|
||||
<el-badge :value="3" :max="99" class="notification-btn">
|
||||
<el-badge :max="99" class="notification-btn">
|
||||
<div class="icon-btn">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
</div>
|
||||
|
||||
317
hzhub-portal-employee/src/layouts/components/LockScreen.vue
Normal file
317
hzhub-portal-employee/src/layouts/components/LockScreen.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<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';
|
||||
|
||||
const lockStore = useLockScreenStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const password = ref('');
|
||||
const errorMsg = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
const nickName = computed(() => userStore.userInfo?.nickName || userStore.userInfo?.username || '用户');
|
||||
|
||||
async function handleUnlock() {
|
||||
if (!password.value) {
|
||||
errorMsg.value = '请输入密码';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorMsg.value = '';
|
||||
try {
|
||||
const ok = await lockStore.unlock(password.value);
|
||||
if (ok) {
|
||||
ElMessage.success('解锁成功');
|
||||
password.value = '';
|
||||
} else {
|
||||
errorMsg.value = '密码错误,请重试';
|
||||
}
|
||||
} catch {
|
||||
errorMsg.value = '网络异常,请重试';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
lockStore.unlockDirect();
|
||||
userStore.logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
function onKeyup(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !loading.value) {
|
||||
handleUnlock();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lock-screen-overlay" @keyup="onKeyup">
|
||||
<div class="lock-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">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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧解锁区域 -->
|
||||
<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-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"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入登录密码解锁"
|
||||
class="lock-input"
|
||||
autofocus
|
||||
@keyup.enter="handleUnlock"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="lock-error">{{ errorMsg }}</div>
|
||||
|
||||
<button class="unlock-btn" :disabled="loading" @click="handleUnlock">
|
||||
<span v-if="!loading">解锁</span>
|
||||
<span v-else>解锁中...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="lock-footer">
|
||||
<span class="footer-text">或</span>
|
||||
<button class="logout-link" @click="handleLogout">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lock-screen-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
.lock-container {
|
||||
display: flex;
|
||||
width: 738px;
|
||||
max-width: 90%;
|
||||
height: 416px;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50%;
|
||||
padding: 32px;
|
||||
background: linear-gradient(233deg, rgba(113, 161, 255, 0.6) 17.67%, rgba(154, 219, 255, 0.6) 70.4%);
|
||||
|
||||
.logo-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.user-icon-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.lock-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.lock-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lock-user {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d5af3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lock-time {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.lock-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
background: #f5f3f0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
background: #fff;
|
||||
border-color: #1d5af3;
|
||||
box-shadow: 0 0 0 3px rgba(29, 90, 243, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lock-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
color: #a8a49c;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-error {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unlock-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #1d5af3, #858bff);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(29, 90, 243, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #1d5af3;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 800px) {
|
||||
.left-section {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.lock-container {
|
||||
height: auto;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.right-section {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StarFilled,
|
||||
Unlock,
|
||||
} from '@element-plus/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useTabbarStore } from '@/stores';
|
||||
@@ -75,15 +75,11 @@ function handleContextMenu(e: MouseEvent, tab: TabItem) {
|
||||
contextMenuTab.value = tab;
|
||||
contextMenuVisible.value = true;
|
||||
|
||||
// 计算菜单位置
|
||||
const container = (e.currentTarget as HTMLElement).closest('.tabs-container');
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
contextMenuStyle.value = {
|
||||
left: `${e.clientX - rect.left}px`,
|
||||
top: `${e.clientY - rect.top}px`,
|
||||
};
|
||||
}
|
||||
// 使用视口坐标定位(fixed),避免被父容器的 overflow 裁剪
|
||||
contextMenuStyle.value = {
|
||||
left: `${e.clientX}px`,
|
||||
top: `${e.clientY}px`,
|
||||
};
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
@@ -93,10 +89,20 @@ function closeContextMenu() {
|
||||
}
|
||||
|
||||
// 点击外部关闭右键菜单
|
||||
function handleClickOutside() {
|
||||
closeContextMenu();
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (contextMenuVisible.value) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
// 获取右键菜单项
|
||||
interface ContextMenuItem {
|
||||
key: string;
|
||||
@@ -250,7 +256,7 @@ function handleMoreCommand(command: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-view" @click="handleClickOutside">
|
||||
<div class="tabs-view">
|
||||
<div class="tabs-container">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
@@ -488,7 +494,7 @@ function handleMoreCommand(command: string) {
|
||||
|
||||
// 右键菜单
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +1,664 @@
|
||||
<!-- CRM 客户管理 -->
|
||||
<script setup lang="ts">
|
||||
// Placeholder for CRM module
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { getCustomerList, type CustomerVO } from '@/api/erp';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// Stats
|
||||
const customerTotal = ref(0);
|
||||
|
||||
const crmStats = computed(() => [
|
||||
{ label: '客户总数', value: customerTotal.value.toLocaleString(), icon: 'UserFilled' as const, change: '+5.2%', up: true, bg: '#eef5ff', color: '#1d5af3' },
|
||||
{ label: '商机金额', value: '¥4,680万', icon: 'Money' as const, change: '+12.8%', up: true, bg: '#f0fdf4', color: '#16a34a' },
|
||||
{ label: '本月赢单', value: '23', icon: 'Trophy' as const, change: '+15%', up: true, bg: '#fef3c7', color: '#d97706' },
|
||||
{ label: '转化率', value: '32%', icon: 'DataLine' as const, change: '-2.1%', up: false, bg: '#fef2f2', color: '#dc2626' },
|
||||
]);
|
||||
|
||||
// Pipeline
|
||||
const activeStage = ref('all');
|
||||
|
||||
const pipeline = [
|
||||
{ key: 'all', label: '全部', color: '#1d5af3', count: 42 },
|
||||
{ key: 'lead', label: '线索', color: '#94a3b8', count: 15 },
|
||||
{ key: 'negotiation', label: '谈判中', color: '#f59e0b', count: 12 },
|
||||
{ key: 'proposal', label: '方案', color: '#8b5cf6', count: 8 },
|
||||
{ key: 'closing', label: '赢单', color: '#22c55e', count: 7 },
|
||||
];
|
||||
|
||||
const opportunities = [
|
||||
{ id: 1, name: '智能制造系统升级', company: '创新科技有限公司', owner: '刘洋', closeDate: '2026-04-30', amount: '¥580万', progress: 75, priority: '高', priorityType: 'danger' as const, stage: 'negotiation', team: [{ name: '刘', color: '#1d5af3' }, { name: '李', color: '#8b5cf6' }] },
|
||||
{ id: 2, name: '数据中心服务器采购', company: '盛通电子集团', owner: '李娜', closeDate: '2026-05-15', amount: '¥1,200万', progress: 40, priority: '高', priorityType: 'danger' as const, stage: 'proposal', team: [{ name: '李', color: '#8b5cf6' }, { name: '张', color: '#16a34a' }, { name: '赵', color: '#d97706' }] },
|
||||
{ id: 3, name: 'ERP系统部署', company: '恒达科技', owner: '张磊', closeDate: '2026-04-20', amount: '¥320万', progress: 90, priority: '中', priorityType: 'warning' as const, stage: 'closing', team: [{ name: '张', color: '#16a34a' }] },
|
||||
{ id: 4, name: 'IoT平台建设', company: '鑫盛电子', owner: '陈晨', closeDate: '2026-06-01', amount: '¥860万', progress: 20, priority: '高', priorityType: 'danger' as const, stage: 'lead', team: [{ name: '陈', color: '#0ea5e9' }, { name: '刘', color: '#1d5af3' }] },
|
||||
{ id: 5, name: '安防监控系统', company: '博远通信', owner: '赵敏', closeDate: '2026-05-20', amount: '¥280万', progress: 55, priority: '中', priorityType: 'warning' as const, stage: 'negotiation', team: [{ name: '赵', color: '#d97706' }] },
|
||||
{ id: 6, name: '云迁移服务', company: '瑞达精密', owner: '刘洋', closeDate: '2026-04-15', amount: '¥450万', progress: 95, priority: '低', priorityType: 'info' as const, stage: 'closing', team: [{ name: '刘', color: '#1d5af3' }, { name: '孙', color: '#dc2626' }] },
|
||||
{ id: 7, name: 'AI质检系统', company: '华信达', owner: '李娜', closeDate: '2026-07-01', amount: '¥680万', progress: 10, priority: '中', priorityType: 'warning' as const, stage: 'lead', team: [{ name: '李', color: '#8b5cf6' }] },
|
||||
];
|
||||
|
||||
function getCardsForStage(stage: string) {
|
||||
if (stage === 'all') return opportunities;
|
||||
return opportunities.filter(o => o.stage === stage);
|
||||
}
|
||||
|
||||
// Customer table
|
||||
const search = ref('');
|
||||
const customers = ref<CustomerVO[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const customerTableData = computed(() => {
|
||||
if (!search.value) return customers.value;
|
||||
const kw = search.value.toLowerCase();
|
||||
return customers.value.filter(c =>
|
||||
c.customerName.toLowerCase().includes(kw)
|
||||
|| c.contactName.toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
function getLevelInfo(item: CustomerVO) {
|
||||
if (item.pricePlanName?.includes('VIP')) return { level: 'VIP', levelType: 'danger' as const };
|
||||
if (item.isStop === 1) return { level: '停用', levelType: 'info' as const };
|
||||
return { level: 'A', levelType: '' as const };
|
||||
}
|
||||
|
||||
function getStatusInfo(item: CustomerVO) {
|
||||
if (item.isStop === 1) return { status: '已停用', statusColor: '#dc2626' };
|
||||
return { status: '活跃', statusColor: '#22c55e' };
|
||||
}
|
||||
|
||||
const logoGradients = [
|
||||
'linear-gradient(135deg, #1d5af3, #3378fc)',
|
||||
'linear-gradient(135deg, #8b5cf6, #a78bfa)',
|
||||
'linear-gradient(135deg, #16a34a, #22c55e)',
|
||||
'linear-gradient(135deg, #d97706, #f59e0b)',
|
||||
'linear-gradient(135deg, #dc2626, #ef4444)',
|
||||
'linear-gradient(135deg, #0ea5e9, #38bdf8)',
|
||||
];
|
||||
|
||||
function getAvatarBg(item: CustomerVO) {
|
||||
const idx = item.customerCode.charCodeAt(item.customerCode.length - 1) % logoGradients.length;
|
||||
return logoGradients[idx];
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getCustomerList({ pageNum: 1, pageSize: 50, keyword: '' });
|
||||
customers.value = res.rows || [];
|
||||
customerTotal.value = res.total || 0;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载客户列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add customer dialog
|
||||
const showAddCustomer = ref(false);
|
||||
const customerForm = ref({ name: '', industry: '', contact: '', phone: '', source: '', estimatedAmount: 0, remark: '' });
|
||||
|
||||
function handleAddCustomer() {
|
||||
ElMessage.success('客户创建成功');
|
||||
showAddCustomer.value = false;
|
||||
customerForm.value = { name: '', industry: '', contact: '', phone: '', source: '', estimatedAmount: 0, remark: '' };
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crm-container">
|
||||
<div class="module-header">
|
||||
<h1 class="module-title">客户管理</h1>
|
||||
<p class="module-subtitle">客户关系管理与跟进系统</p>
|
||||
<div class="crm-page">
|
||||
<!-- Header Stats -->
|
||||
<div class="crm-stats">
|
||||
<div v-for="(s, i) in crmStats" :key="s.label" class="crm-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>
|
||||
|
||||
<div class="module-content">
|
||||
<div class="placeholder-card">
|
||||
<el-icon class="placeholder-icon" color="#1d5af3" :size="64">
|
||||
<User />
|
||||
</el-icon>
|
||||
<h2 class="placeholder-title">功能开发中,敬请期待</h2>
|
||||
<p class="placeholder-text">
|
||||
CRM客户管理模块将提供客户档案管理、跟进记录、客户分级、
|
||||
需求分析、销售漏斗等功能,支持客户全生命周期管理。
|
||||
</p>
|
||||
<div class="placeholder-stats">
|
||||
<div class="stat-item">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>客户档案</span>
|
||||
<!-- Tabs & Filters -->
|
||||
<div class="crm-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="pipeline-tabs">
|
||||
<div
|
||||
v-for="stage in pipeline"
|
||||
:key="stage.key"
|
||||
class="pipe-tab"
|
||||
:class="{ active: activeStage === stage.key }"
|
||||
@click="activeStage = stage.key"
|
||||
>
|
||||
<span class="pipe-dot" :style="{ background: stage.color }"></span>
|
||||
{{ stage.label }}
|
||||
<span class="pipe-count">{{ stage.count }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<el-icon><ChatLineSquare /></el-icon>
|
||||
<span>跟进记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-input v-model="search" placeholder="搜索客户、联系人..." clearable style="width: 220px" />
|
||||
<el-button type="primary" round @click="showAddCustomer = true">
|
||||
<el-icon><Plus /></el-icon> 新建客户
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline View -->
|
||||
<div class="pipeline-view">
|
||||
<div v-for="stage in pipeline" :key="stage.key" class="pipeline-col">
|
||||
<div class="col-header">
|
||||
<span class="col-title">
|
||||
<span class="col-dot" :style="{ background: stage.color }"></span>
|
||||
{{ stage.label }}
|
||||
</span>
|
||||
<span class="col-count">{{ stage.count }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<div v-for="card in getCardsForStage(stage.key)" :key="card.id" class="opportunity-card">
|
||||
<div class="opp-header">
|
||||
<span class="opp-name">{{ card.name }}</span>
|
||||
<el-tag :type="card.priorityType" size="small" effect="plain" round>{{ card.priority }}</el-tag>
|
||||
</div>
|
||||
<div class="opp-company">{{ card.company }}</div>
|
||||
<div class="opp-details">
|
||||
<div class="opp-detail">
|
||||
<el-icon :size="13"><User /></el-icon>
|
||||
<span>{{ card.owner }}</span>
|
||||
</div>
|
||||
<div class="opp-detail">
|
||||
<el-icon :size="13"><Calendar /></el-icon>
|
||||
<span>{{ card.closeDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opp-footer">
|
||||
<span class="opp-amount">{{ card.amount }}</span>
|
||||
<div class="opp-progress">
|
||||
<el-progress :percentage="card.progress" :stroke-width="4" :show-text="false" :color="stage.color" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="opp-avatars">
|
||||
<div v-for="(a, i) in card.team" :key="i" class="mini-avatar" :style="{ background: a.color, zIndex: card.team.length - i }">{{ a.name.charAt(0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
<span>销售漏斗</span>
|
||||
<div class="add-card-placeholder" @click="showAddCustomer = true">
|
||||
<el-icon :size="20"><Plus /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Table -->
|
||||
<div class="customer-section" v-loading="loading">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">客户列表</h3>
|
||||
<el-button text type="primary" size="small">管理客户</el-button>
|
||||
</div>
|
||||
<el-table :data="customerTableData" stripe style="width: 100%">
|
||||
<el-table-column prop="customerName" label="客户名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cust-name-cell">
|
||||
<div class="cust-avatar" :style="{ background: getAvatarBg(row) }">{{ row.customerName.charAt(0) }}</div>
|
||||
<div>
|
||||
<span class="cust-name">{{ row.customerName }}</span>
|
||||
<span class="cust-industry">{{ row.brandName || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户等级" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getLevelInfo(row).levelType" size="small" effect="light" round>{{ getLevelInfo(row).level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactName" label="联系人" width="100" />
|
||||
<el-table-column prop="salesAreaName" label="销区" width="120" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="status-dot-wrap">
|
||||
<span class="status-dot" :style="{ background: getStatusInfo(row).statusColor }"></span>
|
||||
{{ getStatusInfo(row).status }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default>
|
||||
<el-button type="primary" link size="small">详情</el-button>
|
||||
<el-button type="primary" link size="small">跟进</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Add Customer Dialog -->
|
||||
<el-dialog v-model="showAddCustomer" title="新建客户" width="560px" :close-on-click-modal="false">
|
||||
<el-form :model="customerForm" label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户名称">
|
||||
<el-input v-model="customerForm.name" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属行业">
|
||||
<el-input v-model="customerForm.industry" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="customerForm.contact" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="customerForm.phone" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户来源">
|
||||
<el-select v-model="customerForm.source" placeholder="请选择" style="width:100%">
|
||||
<el-option label="主动咨询" value="1" />
|
||||
<el-option label="展会获取" value="2" />
|
||||
<el-option label="老客推荐" value="3" />
|
||||
<el-option label="网络推广" value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预估金额">
|
||||
<el-input-number v-model="customerForm.estimatedAmount" :min="0" :step="10000" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="customerForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddCustomer = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAddCustomer">创建客户</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.crm-container {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background-color: var(--color-bg);
|
||||
.crm-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.module-header {
|
||||
margin-bottom: 24px;
|
||||
.crm-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.crm-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;
|
||||
|
||||
.module-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.module-content {
|
||||
.placeholder-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
|
||||
.placeholder-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px 0;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.placeholder-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-light);
|
||||
|
||||
.el-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
.crm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pipeline-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.pipe-tab {
|
||||
padding: 7px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(29, 90, 243, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pipe-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pipe-count {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.active > .pipe-count { background: rgba(255, 255, 255, 0.2); }
|
||||
}
|
||||
|
||||
.pipeline-view {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pipeline-col {
|
||||
min-width: 280px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.col-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.col-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.col-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.opportunity-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.opp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.opp-name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.opp-company {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.opp-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.opp-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.opp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.opp-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.opp-progress {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.opp-avatars {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -6px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.add-card-placeholder {
|
||||
border: 2px dashed #e8e6e1;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
color: #c4c0b8;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: #f8faff;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-section {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cust-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cust-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cust-name {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.cust-industry {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-dot-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.crm-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.crm-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.crm-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
286
hzhub-portal-employee/src/pages/erp/index.vue
Normal file
286
hzhub-portal-employee/src/pages/erp/index.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<!-- ERP 测试页面 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { testErpConnection, erpHealth, getCustomerList, getSalesAreas, getBrands, type CustomerVO } from '@/api/erp';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// 连接测试
|
||||
const connectionStatus = ref<string>('');
|
||||
const connectionData = ref<any>(null);
|
||||
const healthStatus = ref<string>('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function checkConnection() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await testErpConnection();
|
||||
connectionStatus.value = res.status;
|
||||
connectionData.value = res.data;
|
||||
if (res.status === 'connected') {
|
||||
ElMessage.success('SQL Server 连接成功');
|
||||
} else {
|
||||
ElMessage.error(`连接失败: ${res.data?.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
connectionStatus.value = 'error';
|
||||
connectionData.value = { error: error?.message || '未知错误' };
|
||||
ElMessage.error('请求失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const res = await erpHealth();
|
||||
healthStatus.value = res.msg || '正常';
|
||||
ElMessage.success('ERP 服务运行正常');
|
||||
} catch (error: any) {
|
||||
healthStatus.value = `异常: ${error?.message || '服务不可用'}`;
|
||||
ElMessage.error('ERP 服务不可用');
|
||||
}
|
||||
}
|
||||
|
||||
// 客户档案
|
||||
const customerList = ref<CustomerVO[]>([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref('');
|
||||
const customerLoading = ref(false);
|
||||
const salesAreas = ref<CustomerVO[]>([]);
|
||||
const selectedSalesArea = ref('');
|
||||
const brands = ref<CustomerVO[]>([]);
|
||||
const selectedBrand = ref('');
|
||||
|
||||
async function loadCustomerList() {
|
||||
customerLoading.value = true;
|
||||
try {
|
||||
const res = await getCustomerList({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
salesAreaCode: selectedSalesArea.value || undefined,
|
||||
brand: selectedBrand.value || undefined,
|
||||
});
|
||||
customerList.value = res.rows || [];
|
||||
total.value = res.total || 0;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载客户列表失败');
|
||||
} finally {
|
||||
customerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSalesAreas() {
|
||||
try {
|
||||
const res = await getSalesAreas();
|
||||
salesAreas.value = res || [];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBrands() {
|
||||
try {
|
||||
const res = await getBrands();
|
||||
brands.value = res || [];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pageNum.value = 1;
|
||||
loadCustomerList();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pageNum.value = page;
|
||||
loadCustomerList();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomerList();
|
||||
loadSalesAreas();
|
||||
loadBrands();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="erp-test-page">
|
||||
<h2>ERP 服务</h2>
|
||||
|
||||
<!-- 连接测试 -->
|
||||
<el-card class="test-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>SQL Server 连接测试</span>
|
||||
<el-button type="primary" :loading="loading" @click="checkConnection">
|
||||
测试连接
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="connectionStatus" class="result">
|
||||
<el-alert
|
||||
:title="connectionStatus === 'connected' ? '连接成功' : '连接失败'"
|
||||
:type="connectionStatus === 'connected' ? 'success' : 'error'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<pre v-if="connectionData" class="result-data">{{ JSON.stringify(connectionData, null, 2) }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 健康检查 -->
|
||||
<el-card class="test-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>ERP 服务健康检查</span>
|
||||
<el-button type="success" @click="checkHealth">
|
||||
检查健康
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="healthStatus" class="result">
|
||||
<p>{{ healthStatus }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 客户档案 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>客户档案(SCLTGENERAL)</span>
|
||||
<el-button type="primary" @click="loadCustomerList">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索客户编号/名称/联系人/销区/业务员"
|
||||
clearable
|
||||
style="width: 280px"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="selectedSalesArea"
|
||||
placeholder="选择销区"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="area in salesAreas"
|
||||
:key="area.salesAreaCode"
|
||||
:label="area.salesAreaName"
|
||||
:value="area.salesAreaCode"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="selectedBrand"
|
||||
placeholder="选择品牌"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 150px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in brands"
|
||||
:key="item.brand"
|
||||
:label="item.brandName"
|
||||
:value="item.brand"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<span class="total-info" v-if="total > 0">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 客户列表表格 -->
|
||||
<el-table
|
||||
v-loading="customerLoading"
|
||||
:data="customerList"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%; margin-top: 12px"
|
||||
>
|
||||
<el-table-column prop="customerCode" label="客户编号" width="120" fixed />
|
||||
<el-table-column prop="customerName" label="客户名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="brandName" label="品牌" width="100" />
|
||||
<el-table-column prop="salesAreaName" label="销区" width="100" />
|
||||
<el-table-column prop="sdOrgName" label="经销组织" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="saleDocName" label="销售负责人" width="100" />
|
||||
<el-table-column prop="pricePlanName" label="价格方案" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column prop="province" label="省份" width="80" />
|
||||
<el-table-column prop="city" label="城市" width="100" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="pageNum"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.erp-test-page {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.test-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result {
|
||||
padding: 12px 0;
|
||||
|
||||
.result-data {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.total-info {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -94,12 +94,12 @@ async function handleSubmit() {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await userProfileUpdate(formData);
|
||||
const res = await userProfileUpdate(formData);
|
||||
if (res instanceof Error) return;
|
||||
ElMessage.success('修改成功');
|
||||
emit('update');
|
||||
} catch (error) {
|
||||
console.error('更新失败:', error);
|
||||
ElMessage.error('修改失败');
|
||||
} catch {
|
||||
// 拦截器已显示错误信息
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ 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: '',
|
||||
@@ -101,17 +103,15 @@ async function handleSubmit() {
|
||||
oldPassword: formData.oldPassword,
|
||||
newPassword: formData.newPassword,
|
||||
};
|
||||
await userUpdatePassword(params);
|
||||
const res = await userUpdatePassword(params);
|
||||
// hook-fetch 会将 afterResponse 的 reject 转为 resolve 返回 Error
|
||||
if (res instanceof Error) return;
|
||||
ElMessage.success('密码修改成功,请重新登录');
|
||||
// 清空表单
|
||||
// 清空表单和用户状态
|
||||
resetForm();
|
||||
// 可以选择自动退出登录
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('密码修改失败:', error);
|
||||
ElMessage.error('密码修改失败');
|
||||
userStore.logout();
|
||||
} catch {
|
||||
// 拦截器已显示错误信息
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">
|
||||
<el-tag type="primary" size="small">
|
||||
{{ profile?.user.dept?.deptName || '未分配部门' }}
|
||||
{{ profile?.user.deptName || '未分配部门' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="profile?.postGroup" type="success" size="small" class="ml-2">
|
||||
{{ profile.postGroup }}
|
||||
@@ -116,6 +116,11 @@ async function loadProfile() {
|
||||
try {
|
||||
const resp = await userProfile();
|
||||
|
||||
if (resp instanceof Error) {
|
||||
ElMessage.error('加载用户信息失败,请检查网络连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试处理不同的响应格式
|
||||
let userData = resp;
|
||||
|
||||
@@ -137,7 +142,7 @@ async function loadProfile() {
|
||||
phonenumber: userData.user.phonenumber,
|
||||
email: userData.user.email,
|
||||
deptId: userData.user.deptId,
|
||||
deptName: userData.user.dept?.deptName,
|
||||
deptName: userData.user.deptName,
|
||||
roles: userData.user.roles?.map((r: any) => ({
|
||||
roleId: r.roleId,
|
||||
roleName: r.roleName,
|
||||
@@ -177,14 +182,10 @@ function beforeAvatarUpload(file: File) {
|
||||
|
||||
// 上传头像
|
||||
async function handleAvatarUpload(options: UploadRequestOptions) {
|
||||
try {
|
||||
await userUpdateAvatar(options.file as File);
|
||||
ElMessage.success('头像更新成功');
|
||||
await loadProfile();
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
ElMessage.error('头像上传失败');
|
||||
}
|
||||
const res = await userUpdateAvatar(options.file as File);
|
||||
if (res instanceof Error) return;
|
||||
ElMessage.success('头像更新成功');
|
||||
await loadProfile();
|
||||
}
|
||||
|
||||
onMounted(loadProfile);
|
||||
|
||||
@@ -99,6 +99,16 @@ export const layoutRouter: RouteRecordRaw[] = [
|
||||
// isHide: '1', // 移除隐藏配置,允许添加到标签页
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/erp-test',
|
||||
name: 'erp-test',
|
||||
component: () => import('@/pages/erp/index.vue'),
|
||||
meta: {
|
||||
title: 'ERP测试',
|
||||
subtitle: 'ERP服务连接测试',
|
||||
icon: 'Monitor',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/chat/:id',
|
||||
name: 'chatWithId',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useKeepAliveStore = defineStore(
|
||||
@@ -27,6 +28,8 @@ export const useKeepAliveStore = defineStore(
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
persist: {
|
||||
storage: sessionStorage, // 使用 sessionStorage,浏览器关闭后自动清除
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
72
hzhub-portal-employee/src/stores/modules/lockScreen.ts
Normal file
72
hzhub-portal-employee/src/stores/modules/lockScreen.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
|
||||
export const useLockScreenStore = defineStore(
|
||||
'lockScreen',
|
||||
() => {
|
||||
const isLocked = ref(false);
|
||||
const lockTime = ref<string | null>(null);
|
||||
|
||||
/**
|
||||
* 锁屏(保存当前时间)
|
||||
*/
|
||||
function lock() {
|
||||
isLocked.value = true;
|
||||
lockTime.value = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试解锁(验证密码)
|
||||
* 使用原生 fetch 绕过 hook-fetch 拦截器,避免 body 流消费问题
|
||||
*/
|
||||
async function unlock(password: string): Promise<boolean> {
|
||||
try {
|
||||
const userStore = useUserStore();
|
||||
const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/verifyPassword`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${userStore.token}`,
|
||||
'ClientID': import.meta.env.VITE_CLIENT_ID,
|
||||
...(userStore.userInfo?.tenantId ? { 'tenant-id': userStore.userInfo.tenantId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
console.log('解锁响应:', data);
|
||||
if (data?.code === 200 && data?.data === true) {
|
||||
isLocked.value = false;
|
||||
lockTime.value = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('解锁请求异常:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁(不调用接口,直接解锁)
|
||||
*/
|
||||
function unlockDirect() {
|
||||
isLocked.value = false;
|
||||
lockTime.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
isLocked,
|
||||
lockTime,
|
||||
lock,
|
||||
unlock,
|
||||
unlockDirect,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
storage: sessionStorage, // 使用 sessionStorage,浏览器关闭后自动清除
|
||||
pick: ['isLocked', 'lockTime'],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -235,6 +235,15 @@ export const useTabbarStore = defineStore('tabbar', {
|
||||
this.activeTab = path;
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置标签页(退出登录时调用)
|
||||
*/
|
||||
resetTabs() {
|
||||
this.tabs = [];
|
||||
this.activeTab = HOME_PATH;
|
||||
this.cachedTabs = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化固定标签页
|
||||
*/
|
||||
@@ -274,6 +283,7 @@ export const useTabbarStore = defineStore('tabbar', {
|
||||
|
||||
persist: {
|
||||
key: 'hzhub-employee-tabs',
|
||||
storage: sessionStorage, // 使用 sessionStorage,浏览器关闭后自动清除
|
||||
paths: ['tabs', 'activeTab', 'cachedTabs'],
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import type { LoginUser } from '@/api/auth/types';
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTabbarStore } from './tabbar';
|
||||
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
const tabbarStore = useTabbarStore();
|
||||
const token = ref<string>();
|
||||
const router = useRouter();
|
||||
const setToken = (value: string) => {
|
||||
@@ -24,7 +26,9 @@ export const useUserStore = defineStore(
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
// 如果需要调用接口,可以在这里调用
|
||||
// 清空标签页
|
||||
tabbarStore.resetTabs();
|
||||
// 清空用户信息
|
||||
clearToken();
|
||||
clearUserInfo();
|
||||
// 跳转到登录页面
|
||||
@@ -59,6 +63,8 @@ export const useUserStore = defineStore(
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
persist: {
|
||||
storage: sessionStorage, // 使用 sessionStorage,浏览器关闭后自动清除
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useUserStore } from '@/stores';
|
||||
|
||||
interface BaseResponse {
|
||||
code: number;
|
||||
data: never;
|
||||
data?: any;
|
||||
msg: string;
|
||||
rows: never;
|
||||
rows?: any;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export const request = hookFetch.create<BaseResponse, 'data' | 'rows'>({
|
||||
@@ -72,30 +73,39 @@ function jwtPlugin(): HookFetchPlugin<BaseResponse> {
|
||||
return config;
|
||||
},
|
||||
afterResponse: async (response) => {
|
||||
// console.log(response);
|
||||
const resp = response.response;
|
||||
// 判断 result 是否已被解析为对象(非 Response)
|
||||
const body = (response.result && !(response.result instanceof Response))
|
||||
? response.result
|
||||
: (resp?.ok ? await resp.json() : await resp.clone().json());
|
||||
|
||||
// 成功响应
|
||||
if (response.result?.code === 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const errorMsg = translateError(response.result?.msg);
|
||||
|
||||
// 处理403:静默拒绝,由业务代码自行处理
|
||||
if (response.result?.code === 403) {
|
||||
if (!resp?.ok) {
|
||||
const errorMsg = translateError(body?.msg);
|
||||
if (body?.code === 401) {
|
||||
userStore.logout();
|
||||
}
|
||||
ElMessage.error(errorMsg || '请求失败');
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
// 处理401逻辑
|
||||
if (response.result?.code === 401) {
|
||||
response.result = body;
|
||||
|
||||
if (body?.code === 200) {
|
||||
// 返回完整的响应对象,包含 code, data, msg
|
||||
// 前端代码需要从 result.data 中提取实际数据
|
||||
return response;
|
||||
}
|
||||
|
||||
const errorMsg = translateError(body?.msg);
|
||||
if (body?.code === 403) {
|
||||
return Promise.reject(response);
|
||||
}
|
||||
if (body?.code === 401) {
|
||||
userStore.logout();
|
||||
ElMessage.error(errorMsg || '登录已过期,请重新登录');
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
// 其他错误:显示错误信息
|
||||
ElMessage.error(errorMsg);
|
||||
|
||||
ElMessage.error(errorMsg || '请求失败');
|
||||
return Promise.reject(response);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user