refactor(客户管理): 完成客户管理模块UI与架构全面调整
1. 调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由 2. 后端新增客户统计接口`/customers/stats`,前端同步API与类型定义 3. 重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能 4. 新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图 5. 更新数据库种子数据与权限配置,清理废弃菜单与绑定关系 6. 补充UI设计文档与规范说明
This commit is contained in:
1 parent
2a06cfea6d
commit
c60caa4770
35 files changed
+7524
-1961
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Op, UniqueConstraintError, fn, col } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerDepartment from "../entity/customer_department.entity";
|
||||
@@ -318,4 +318,50 @@ export class CustomerService {
|
||||
data: rows.map((r) => r.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(ctx: Context): Promise<any> {
|
||||
const userId = ctx.state.user.userId;
|
||||
const isAdmin = await this.isAdmin(userId);
|
||||
|
||||
const where: any = {};
|
||||
if (!isAdmin) {
|
||||
where.salesmanId = userId;
|
||||
}
|
||||
|
||||
const total = await this.customerModel.count({ where });
|
||||
|
||||
const statusRows = await this.customerModel.findAll({
|
||||
where,
|
||||
attributes: ["status", [fn("COUNT", col("id")), "count"]],
|
||||
group: ["status"],
|
||||
raw: true
|
||||
});
|
||||
|
||||
const levelRows = await this.customerModel.findAll({
|
||||
where,
|
||||
attributes: ["customer_level", [fn("COUNT", col("id")), "count"]],
|
||||
group: ["customer_level"],
|
||||
raw: true
|
||||
});
|
||||
|
||||
const statusCounts: Record<number, number> = {};
|
||||
for (const row of statusRows as any[]) {
|
||||
if (row.status !== null && row.status !== undefined) {
|
||||
statusCounts[row.status] = Number(row.count) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const levelCounts: Record<string, number> = {};
|
||||
for (const row of levelRows as any[]) {
|
||||
if (row.customer_level) {
|
||||
levelCounts[row.customer_level] = Number(row.count) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取客户统计成功",
|
||||
data: { total, statusCounts, levelCounts }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,179 +1,269 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import SysOperationLog from "../entity/sysOperationLog.entity";
|
||||
import dayjs from "dayjs";
|
||||
import { Op } from "sequelize";
|
||||
import { Op, fn } from "sequelize";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import Contract from "../entity/contract.entity";
|
||||
import SurveyOrder from "../entity/survey_order.entity";
|
||||
import ProjectPlan from "../entity/project_plan.entity";
|
||||
import Acceptance from "../entity/acceptance.entity";
|
||||
import VisitRecord from "../entity/visit_record.entity";
|
||||
|
||||
@Provide()
|
||||
export class DashboardService {
|
||||
@InjectRepository(SysOperationLog)
|
||||
logModel!: typeof SysOperationLog;
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: typeof Customer;
|
||||
|
||||
@InjectRepository(Contract)
|
||||
contractModel!: typeof Contract;
|
||||
|
||||
@InjectRepository(SurveyOrder)
|
||||
surveyModel!: typeof SurveyOrder;
|
||||
|
||||
@InjectRepository(ProjectPlan)
|
||||
planModel!: typeof ProjectPlan;
|
||||
|
||||
@InjectRepository(Acceptance)
|
||||
acceptModel!: typeof Acceptance;
|
||||
|
||||
@InjectRepository(VisitRecord)
|
||||
visitModel!: typeof VisitRecord;
|
||||
|
||||
async getDashboardStats(): Promise<any> {
|
||||
const mockDeviceTotal = 1256;
|
||||
const mockDeviceOnline = 1089;
|
||||
const mockAlertToday = 23;
|
||||
// 客户统计
|
||||
const customerTotal = await this.countModel(this.customerModel);
|
||||
const customerMonthlyNew = await this.countModel(this.customerModel, {
|
||||
[Op.gte]: dayjs().startOf("month").toDate()
|
||||
});
|
||||
|
||||
const activeUsers = await this.getActiveUserCount(7);
|
||||
// 有效合同与年度金额
|
||||
const activeContractTotal = await this.countModel(this.contractModel, undefined, {
|
||||
status: 1
|
||||
});
|
||||
const yearContracts = await this.contractModel.findAll({
|
||||
where: {
|
||||
status: 1,
|
||||
created_at: { [Op.gte]: dayjs().startOf("year").toDate() }
|
||||
},
|
||||
attributes: ["contract_amount"]
|
||||
});
|
||||
const contractYearAmount = yearContracts.reduce(
|
||||
(sum: number, c: Contract) => sum + Number(c.contractAmount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const loginTrend = await this.getLoginTrend(7);
|
||||
// 勘察派单
|
||||
const surveyPending = await this.countModel(this.surveyModel, undefined, {
|
||||
status: { [Op.lt]: 2 }
|
||||
});
|
||||
const surveyTotal = await this.countModel(this.surveyModel);
|
||||
|
||||
const deviceTrend = this.generateMockTrend(7, 900, 1100);
|
||||
// 本月回访
|
||||
const visitMonthly = await this.countModel(this.visitModel, {
|
||||
[Op.gte]: dayjs().startOf("month").toDate()
|
||||
});
|
||||
const visitTotal = await this.countModel(this.visitModel);
|
||||
|
||||
const deviceDistribution = [
|
||||
{ name: "智能电表", value: 456 },
|
||||
{ name: "环境传感器", value: 328 },
|
||||
{ name: "摄像头", value: 245 },
|
||||
{ name: "门禁系统", value: 147 },
|
||||
{ name: "其他设备", value: 80 }
|
||||
// 方案 / 验收
|
||||
const planTotal = await this.countModel(this.planModel);
|
||||
const acceptTotal = await this.countModel(this.acceptModel);
|
||||
|
||||
// 核心业务数据总览条(同步状态)
|
||||
const modules = [
|
||||
{ name: "托管客户", value: customerTotal },
|
||||
{ name: "有效合同", value: activeContractTotal },
|
||||
{ name: "勘察派单", value: surveyTotal },
|
||||
{ name: "改造方案", value: planTotal },
|
||||
{ name: "项目验收", value: acceptTotal },
|
||||
{ name: "客户回访", value: visitTotal }
|
||||
];
|
||||
|
||||
const recentAlerts = this.generateMockAlerts(10);
|
||||
// 近7日业务增长趋势(新增客户 + 新增合同)
|
||||
const trend = await this.getTrend(7);
|
||||
|
||||
const recentActivities = await this.getRecentActivities(10);
|
||||
// 客户状态分布
|
||||
const customerStatusMap = await this.getCustomerStatus();
|
||||
const signedCount =
|
||||
(customerStatusMap[2] || 0) + (customerStatusMap[3] || 0);
|
||||
const customerSignRate =
|
||||
customerTotal > 0
|
||||
? Math.round((signedCount / customerTotal) * 1000) / 10
|
||||
: 0;
|
||||
const customerStatus = [
|
||||
{ name: "意向客户", value: customerStatusMap[1] || 0 },
|
||||
{ name: "签约客户", value: customerStatusMap[2] || 0 },
|
||||
{ name: "运维中", value: customerStatusMap[3] || 0 },
|
||||
{ name: "已终止", value: customerStatusMap[4] || 0 }
|
||||
];
|
||||
|
||||
// 待办勘察派单流水
|
||||
const surveyList = await this.getSurveyList(5);
|
||||
|
||||
// 智电工程推进看板
|
||||
const projects = await this.getProjectKanban(3);
|
||||
|
||||
return {
|
||||
deviceTotal: mockDeviceTotal,
|
||||
deviceOnline: mockDeviceOnline,
|
||||
alertToday: mockAlertToday,
|
||||
activeUsers,
|
||||
deviceTrend,
|
||||
deviceTypeDistribution: deviceDistribution,
|
||||
recentAlerts,
|
||||
recentActivities
|
||||
customerTotal,
|
||||
customerMonthlyNew,
|
||||
contractTotal: activeContractTotal,
|
||||
contractYearAmount,
|
||||
surveyPending,
|
||||
surveyTotal,
|
||||
visitMonthly,
|
||||
visitTotal,
|
||||
planTotal,
|
||||
acceptTotal,
|
||||
surveyByStatus: [
|
||||
{ name: "已派发", value: await this.countModel(this.surveyModel, undefined, { status: 0 }) },
|
||||
{ name: "已接单", value: await this.countModel(this.surveyModel, undefined, { status: 1 }) },
|
||||
{ name: "已完成", value: await this.countModel(this.surveyModel, undefined, { status: 2 }) }
|
||||
],
|
||||
modules,
|
||||
trend,
|
||||
customerStatus,
|
||||
customerSignRate,
|
||||
surveyList,
|
||||
projects
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveUserCount(days: number): Promise<number> {
|
||||
const cutoffDate = dayjs().subtract(days, "day").toDate();
|
||||
|
||||
private async countModel(
|
||||
model: any,
|
||||
dateRange?: any,
|
||||
extraWhere?: Record<string, any>
|
||||
): Promise<number> {
|
||||
const where: Record<string, any> = { ...(extraWhere || {}) };
|
||||
if (dateRange) {
|
||||
where.created_at = dateRange;
|
||||
}
|
||||
try {
|
||||
const count = await this.logModel.count({
|
||||
where: {
|
||||
action: "用户登录",
|
||||
created_at: { [Op.gte]: cutoffDate }
|
||||
},
|
||||
distinct: true,
|
||||
col: "userId"
|
||||
});
|
||||
|
||||
return count || 12;
|
||||
const count = await model.count({ where });
|
||||
return Number(count) || 0;
|
||||
} catch (error) {
|
||||
return 12;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async getLoginTrend(days: number): Promise<any[]> {
|
||||
const trend = [];
|
||||
|
||||
private async getTrend(days: number): Promise<any[]> {
|
||||
const trend: any[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = dayjs().subtract(i, "day").format("YYYY-MM-DD");
|
||||
|
||||
try {
|
||||
const count = await this.logModel.count({
|
||||
where: {
|
||||
action: "用户登录",
|
||||
created_at: {
|
||||
[Op.between]: [dayjs(date).startOf("day").toDate(), dayjs(date).endOf("day").toDate()]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
trend.push({
|
||||
date,
|
||||
value: count || Math.floor(Math.random() * 50) + 20
|
||||
});
|
||||
} catch (error) {
|
||||
trend.push({
|
||||
date,
|
||||
value: Math.floor(Math.random() * 50) + 20
|
||||
});
|
||||
}
|
||||
const date = dayjs().subtract(i, "day");
|
||||
const range = {
|
||||
[Op.between]: [date.startOf("day").toDate(), date.endOf("day").toDate()]
|
||||
};
|
||||
const customers = await this.countModel(this.customerModel, range);
|
||||
const contracts = await this.countModel(this.contractModel, range);
|
||||
trend.push({
|
||||
date: date.format("YYYY-MM-DD"),
|
||||
customers,
|
||||
contracts
|
||||
});
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
private async getRecentActivities(limit: number): Promise<any[]> {
|
||||
private async getCustomerStatus(): Promise<Record<number, number>> {
|
||||
try {
|
||||
const logs = await this.logModel.findAll({
|
||||
const rows = await this.customerModel.findAll({
|
||||
attributes: [
|
||||
"status",
|
||||
[fn("COUNT", "*"), "count"]
|
||||
],
|
||||
group: ["status"],
|
||||
raw: true
|
||||
});
|
||||
const map: Record<number, number> = {};
|
||||
rows.forEach((row: any) => {
|
||||
const s = Number(row.status);
|
||||
map[s] = Number(row.count) || 0;
|
||||
});
|
||||
return map;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async getSurveyList(limit: number): Promise<any[]> {
|
||||
try {
|
||||
const rows = await this.surveyModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Customer,
|
||||
attributes: ["customerName", "city", "address"]
|
||||
}
|
||||
],
|
||||
order: [["created_at", "DESC"]],
|
||||
limit,
|
||||
attributes: ["id", "action", "module", "ip", "created_at"]
|
||||
attributes: ["id", "surveyNo", "surveyor", "status", "created_at"]
|
||||
});
|
||||
return rows.map((o: SurveyOrder) => {
|
||||
const cust: any = (o as any).customer || {};
|
||||
return {
|
||||
id: o.id,
|
||||
orderNo: o.surveyNo,
|
||||
customerName: cust.customerName || "--",
|
||||
locale:
|
||||
[cust.city, cust.address].filter(Boolean).join(" ") || "--",
|
||||
owner: o.surveyor || "待指派",
|
||||
status: o.status,
|
||||
time: dayjs(o.created_at).format("MM-DD HH:mm")
|
||||
};
|
||||
});
|
||||
|
||||
return logs.map((log) => ({
|
||||
id: log.id,
|
||||
user: String(log.userId || "系统"),
|
||||
action: log.action,
|
||||
module: log.module,
|
||||
ip: log.ip,
|
||||
time: dayjs(log.created_at).format("MM-DD HH:mm")
|
||||
}));
|
||||
} catch (error) {
|
||||
return this.generateMockActivities(limit);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private generateMockTrend(days: number, min: number, max: number): any[] {
|
||||
const trend = [];
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
trend.push({
|
||||
date: dayjs().subtract(i, "day").format("YYYY-MM-DD"),
|
||||
online: Math.floor(Math.random() * (max - min)) + min,
|
||||
offline: Math.floor(Math.random() * 200) + 50
|
||||
private async getProjectKanban(limit: number): Promise<any[]> {
|
||||
try {
|
||||
const acceptedIds = new Set(
|
||||
(
|
||||
await this.acceptModel.findAll({
|
||||
attributes: ["customer_id"]
|
||||
})
|
||||
).map((r: any) => String(r.customer_id))
|
||||
);
|
||||
const rows = await this.contractModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Customer,
|
||||
attributes: ["customerName"]
|
||||
},
|
||||
{
|
||||
model: ProjectPlan,
|
||||
attributes: ["planDesc"]
|
||||
}
|
||||
],
|
||||
where: { status: 1 },
|
||||
order: [["created_at", "DESC"]],
|
||||
limit,
|
||||
attributes: [
|
||||
"id",
|
||||
"contractNo",
|
||||
"contractAmount",
|
||||
"customerId"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
private generateMockAlerts(count: number): any[] {
|
||||
const alertTypes = ["离线告警", "异常数据", "通信故障", "电量低", "温度超标"];
|
||||
const devices = ["电表#1001", "传感器#2003", "摄像头#3012", "门禁#4005"];
|
||||
const levels = ["high", "medium", "low"];
|
||||
|
||||
const alerts = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
alerts.push({
|
||||
id: i + 1,
|
||||
type: alertTypes[Math.floor(Math.random() * alertTypes.length)],
|
||||
device: devices[Math.floor(Math.random() * devices.length)],
|
||||
level: levels[Math.floor(Math.random() * levels.length)],
|
||||
time: dayjs()
|
||||
.subtract(Math.floor(Math.random() * 60), "minute")
|
||||
.format("HH:mm"),
|
||||
status: Math.random() > 0.5 ? "pending" : "resolved"
|
||||
return rows.map((c: Contract) => {
|
||||
const cust: any = (c as any).customer || {};
|
||||
const plan: any = (c as any).plan || {};
|
||||
const accepted = acceptedIds.has(String(c.customerId));
|
||||
return {
|
||||
id: c.id,
|
||||
customerName: cust.customerName || "--",
|
||||
amount: Number(c.contractAmount || 0),
|
||||
stage: accepted ? 4 : 3,
|
||||
stageLabel: accepted ? "项目验收" : "设备安装调试",
|
||||
progress: accepted ? 100 : 80,
|
||||
owner: "工程部",
|
||||
desc:
|
||||
(plan && plan.planDesc) ||
|
||||
(cust.customerName || "--") + "智能配用电改造项目",
|
||||
orderNo: c.contractNo
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return alerts.sort((a, b) => a.time.localeCompare(b.time));
|
||||
}
|
||||
|
||||
private generateMockActivities(count: number): any[] {
|
||||
const actions = [
|
||||
{ user: "管理员", action: "用户登录", module: "认证" },
|
||||
{ user: "操作员A", action: "修改设备配置", module: "设备管理" },
|
||||
{ user: "管理员", action: "创建新用户", module: "用户管理" },
|
||||
{ user: "操作员B", action: "查看告警列表", module: "监控中心" },
|
||||
{ user: "系统", action: "自动备份完成", module: "系统" },
|
||||
{ user: "操作员A", action: "导出数据报表", module: "数据分析" },
|
||||
{ user: "管理员", action: "更新系统配置", module: "系统设置" },
|
||||
{ user: "操作员C", action: "处理告警事件", module: "监控中心" },
|
||||
{ user: "系统", action: "定时任务执行", module: "系统" },
|
||||
{ user: "操作员B", action: "添加新设备", module: "设备管理" }
|
||||
];
|
||||
|
||||
return actions.slice(0, count).map((item, index) => ({
|
||||
id: index + 1,
|
||||
...item,
|
||||
ip: "192.168.1." + Math.floor(Math.random() * 255),
|
||||
time: dayjs()
|
||||
.subtract(index * 30, "minute")
|
||||
.format("MM-DD HH:mm")
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user