refactor(客户管理): 完成客户管理模块UI与架构全面调整

1.  调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由
2.  后端新增客户统计接口`/customers/stats`,前端同步API与类型定义
3.  重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能
4.  新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图
5.  更新数据库种子数据与权限配置,清理废弃菜单与绑定关系
6.  补充UI设计文档与规范说明
This commit is contained in:
andyesfly committed 2026-09-09 17:31:24 +08:00
1 parent 2a06cfea6d
commit c60caa4770
35 files changed
+7524 -1961

No files matched your search

@@ -22,6 +22,12 @@ export class CustomerController {
return result;
}
@Get("/stats")
async getStats() {
const result = await this.customerService.getStats(this.ctx);
return result;
}
@Get("/:id")
async detail(@Param("id") id: string) {
const result = await this.customerService.detail(Number(id), this.ctx);
+47 -1
View File
@@ -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 }
};
}
}
+227 -137
View File
@@ -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")
}));
}
}
}