更新项目管理模块中合同管理、项目安装、项目验收、回访记录等功能

This commit is contained in:
wk committed 2026-09-30 17:04:55 +08:00
1 parent 307e9cf1b9
commit b3cef22a54
32 files changed
+5920 -2426

No files matched your search

@@ -0,0 +1,31 @@
# Checklist
## 后端:安装派单
- [x] `install_order` entity 字段与 db_design.html D6 完全一致,含注释与索引
- [x] `POST /api/install-orders` 创建成功并自动生成唯一 install_no,返回统一结构
- [x] `GET /api/install-orders` 支持关键字/客户/状态/日期范围筛选与分页
- [x] `PUT /api/install-orders/:id/complete` 仅允许 0→1 流转,重复调用被拒绝
- [x] 非法入参(缺必填/超长/非法枚举)返回 422
## 后端:安全
- [x] 六个项目管理控制器全部端点:无 JWT 返回 401,无权限点返回 403
- [x] `sys_menu` 存在 `project:install` 权限点且 RBAC 匹配生效
- [x] 六个 service 无字符串拼接 SQL,模糊搜索全部绑定参数,排序字段白名单
- [x] 六个 DTO 对全部外部入参有 class-validator 校验
- [x] 创建/更新/删除/指派/流转/取消/复核/催办动作均写入 sys_operation_log
## 前端:页面
- [x] 勘察台账页含统计卡、状态Tab、高级筛选、台账列、批量指派/催办入口,数据与接口联通
- [x] 勘察派单页含报价联动、优先级、到场窗口、草稿/派发,提交与接口联通
- [x] 方案页含设备明细编辑,合同页含金额/支付周期/文件,验收页含关联合同,均为拜访台账式交互
- [x] 安装派单页 CRUD + 标记完成可用,路由 `/project/install` 与菜单项生效
- [x] 回访记录页列表/新建/编辑可用
## 前端:安全与兼容
- [x] `views/project/**` 无 `v-html` 渲染后端文本
- [x] 401 自动跳登录,403/422 有统一提示
- [x] 所有列表接口响应为 `{ code, message, data: { list, total, page, pageSize } }`
## 构建
- [x] `cd client && npx vue-tsc --noEmit` 通过(vue-tsc 无类型错误)
- [x] `cd server && npx tsc --noEmit` 通过(tsc 无类型错误)
@@ -0,0 +1,70 @@
# 项目管理模块全栈重建 Spec
## Why
项目管理模块(勘察派单/方案/合同/安装/验收/回访)后端代码已存在但前端页面未按系统设计稿实现,安装派单(install_order)全栈缺失;同时需要系统性补齐接口鉴权、SQL 注入防护、前端 XSS 防护与操作日志,并保证同一套 API 可被 APP 直接复用。
## What Changes
- **前端重建**:按设计稿重建勘察台账页(参考 `系统设计/UI/项目勘察台帐/code.html`)与勘察派单页(参考 `系统设计/UI/项目勘察派单/code.html`);方案/合同/验收/安装/回访页按客户拜访管理台账(`client/src/views/customer/visit/index.vue`)的交互与样式体系重建。
- **安装派单全栈新增**:install_order 的 entity / dto / service / controller / 前端台账页 / API / 路由,功能深度为台账式完整 CRUD + 状态流转(0已派发 → 1已完成)。
- **安全加固**:
- 接口鉴权补全:项目管理所有接口统一走 JWT 认证 + RBAC 权限点(复用现有 `RbacMiddleware`,按 path 匹配 permission code)。
- SQL 注入防护审查:全部查询走 Sequelize 参数化,禁止字符串拼接 SQL;DTO 层用 class-validator 全量校验入参。
- 前端 XSS 防护:用户输入的备注/说明/意见等文本字段渲染时禁止 `v-html`,统一文本插值;富文本场景如需 HTML 必须经白名单消毒。
- 操作日志埋点:创建/更新/删除/指派/状态流转/取消/复核等关键动作写入 `sys_operation_log`。
- **APP 兼容**:不新增命名空间,Web 与 APP 共用现有 `/api/project-*` 系列接口;统一响应结构 `{ code, message, data }`,分页结构统一为 `{ list, total, page, pageSize }`;字段命名与时间格式(ISO 8601)保持稳定。
## Impact
- Affected specs: 项目管理(新建)、系统安全(加固)
- Affected code:
- server:`src/entity/install_order.entity.ts`(新增)、`src/dto/install_order.dto.ts`(新增)、`src/service/install_order.service.ts`(新增)、`src/controller/install_order.controller.ts`(新增)、既有 project_plan / contract / survey_order / acceptance / visit_record 的 controller/service(安全审查与日志埋点)
- client:`src/views/project/**`(全部重建)、`src/api/installOrder.ts`(新增)、`src/router/index.ts`(补安装派单路由)、`src/layouts/index.vue`(菜单补项)
## ADDED Requirements
### Requirement: 安装派单管理(install_order 全栈)
系统 SHALL 提供安装派单的台账式完整 CRUD 与状态流转:列表分页筛选(关键字/客户/状态/派单日期范围)、新建、编辑、详情、删除、标记完成。单号自动生成(格式与勘察单号规则一致,如 `WH-INST-YYYY-NNNN`)。
#### Scenario: 创建安装派单
- **WHEN** 授权用户提交合法派单表单(customerId、planId、installer、dispatchDate 必填)
- **THEN** 系统生成唯一 install_no,状态为 0(已派发),写入操作日志,返回统一成功响应
#### Scenario: 状态流转
- **WHEN** 授权用户对已派发工单执行「标记完成」
- **THEN** 状态从 0 流转为 1,记录完成时间,写入操作日志;已完成工单禁止再次流转
### Requirement: 勘察台账页按设计稿重建
系统 SHALL 按 `系统设计/UI/项目勘察台帐/code.html` 重建勘察台账页:统计卡片(待指派/勘察中等待复核/已归档)、状态筛选 Tab、高级筛选(优先级/业务员/电工/时间窗口)、列表列(工单编号+优先级、关联客户与项目、空间位置、测绘回路规格、责任业务员、指派电工、期望时效/SLA、预估劳务费、状态、操作)、批量指派、催办、智能派单入口。
#### Scenario: 台账筛选
- **WHEN** 用户组合使用状态 Tab 与高级筛选条件
- **THEN** 列表与统计卡片按筛选结果刷新,分页保持统一响应结构
### Requirement: 勘察派单页按设计稿重建
系统 SHALL 按 `系统设计/UI/项目勘察派单/code.html` 重建派单表单页:关联客户选择、任务标题、空间位置、回路规格/数量、优先级(特急P1/加急P2/常规P3)、计划到场窗口、SLA、预估劳务费自动报价(复用 `/api/survey-orders/quote`)、指派电工、草稿保存与正式派发。
### Requirement: 方案/合同/验收/回访页重建
系统 SHALL 按客户拜访台账的交互体系(筛选区 + 表格 + 分页 + 新建/编辑弹窗或表单页 + 状态标签)重建项目方案(含设备明细)、合同(含金额/支付周期/合同文件)、项目验收(含验收意见/文件)、回访记录四个台账页。
### Requirement: 接口鉴权补全
系统 SHALL 保证 `/api/survey-orders`、`/api/project-plans`、`/api/contracts`、`/api/install-orders`、`/api/acceptances`、`/api/visit-records` 全部端点强制 JWT 认证并校验对应 RBAC 权限点(`project:survey`、`project:plan`、`project:contract`、`project:install`、`project:acceptance`、`project:visit`)。
#### Scenario: 未认证访问被拒
- **WHEN** 请求未携带有效 JWT
- **THEN** 返回 401 统一错误结构;无权限点返回 403
### Requirement: SQL 注入防护审查
系统 SHALL 保证项目管理模块所有数据库访问经由 Sequelize 参数化;关键字模糊搜索使用绑定参数的 `Op.like`,排序字段白名单校验,禁止任何字符串拼接 SQL。
### Requirement: 前端 XSS 防护
系统 SHALL 禁止对后端返回的用户输入文本使用 `v-html`;表格/详情中的备注、说明、意见字段使用文本插值渲染。
### Requirement: 操作日志埋点
系统 SHALL 在创建/更新/删除/指派/批量指派/状态流转/取消/复核/催办动作中写入 `sys_operation_log`(操作人、动作、目标单号、摘要)。
## MODIFIED Requirements
### Requirement: 统一响应结构(APP 兼容)
项目管理全部接口 SHALL 返回 `{ code, message, data }`;列表 data 统一为 `{ list, total, page, pageSize }`;时间字段序列化为 ISO 8601 字符串;错误码语义稳定(401 未认证 / 403 无权限 / 422 参数校验失败 / 500 服务器错误),供 APP 端直接消费。
@@ -0,0 +1,37 @@
# Tasks
## 后端:安装派单(新增)
- [x] Task 1: 新增 install_order 后端全栈
- [x] 1.1 创建 `src/entity/install_order.entity.ts`(字段对齐 db_design.html D6:install_no/customer_id/installer/worker_id/dispatch_date/start_date/end_date/plan_id/remark/status/created_at)
- [x] 1.2 创建 `src/dto/install_order.dto.ts`(class-validator:QueryDTO/CreateDTO/UpdateDTO,必填校验、长度限制、状态枚举校验)
- [x] 1.3 创建 `src/service/install_order.service.ts`(list 分页+筛选、detail、create 生成单号、update、delete、complete 状态流转;全程参数化查询)
- [x] 1.4 创建 `src/controller/install_order.controller.ts`(`/api/install-orders`:GET /、GET /:id、POST /、PUT /:id、PUT /:id/complete、DELETE /:id)
## 后端:安全加固(可并行)
- [x] Task 2: 接口鉴权补全
- [x] 2.1 核对六个项目管理控制器的 JWT + RbacMiddleware 挂载,确认 path→permission code 映射覆盖(project:survey/plan/contract/install/acceptance/visit)
- [x] 2.2 在 `sys_menu` 种子/迁移中补齐 `project:install` 权限点
- [x] Task 3: SQL 注入防护审查
- [x] 3.1 审查六个 service,确认无字符串拼接 SQL、模糊搜索均为绑定参数 `Op.like`、排序字段白名单
- [x] 3.2 审查六个 DTO,确认所有外部入参均有 class-validator 校验(类型/长度/枚举/范围)
- [x] Task 4: 操作日志埋点
- [x] 4.1 封装/复用操作日志写入方法(写 sys_operation_log:操作人、模块、动作、目标单号、摘要)
- [x] 4.2 在六个 service 的 create/update/delete/assign/batchAssign/状态流转/cancel/review/urge/complete 处埋点
## 前端:页面重建
- [x] Task 5: 重建勘察台账页 `views/project/survey/index.vue`(对齐 `系统设计/UI/项目勘察台帐/code.html`:统计卡、状态Tab、高级筛选、台账列、批量指派/催办/智能派单入口)
- [x] Task 6: 重建勘察派单页 `views/project/survey/form.vue`(对齐 `系统设计/UI/项目勘察派单/code.html`:客户选择、回路规格/数量、优先级、到场窗口、SLA、劳务费报价联动、草稿/派发)
- [x] Task 7: 重建方案管理页 `views/project/plan/index.vue`(拜访台账式:筛选+表格+分页+新建/编辑含设备明细+状态标签)
- [x] Task 8: 重建合同管理页 `views/project/contract/index.vue`(筛选+表格+新建/编辑含金额/支付周期/合同文件上传+状态标签)
- [x] Task 9: 重建项目验收页 `views/project/acceptance/index.vue`(筛选+表格+新建/编辑含验收意见/文件+关联合同)
- [x] Task 10: 新增安装派单页 `views/project/install/index.vue` + `src/api/installOrder.ts` + 路由 `/project/install` + 布局菜单项(台账式 CRUD + 标记完成)
- [x] Task 11: 重建回访记录页 `views/project/visit/index.vue` + 补齐 `src/api/visitRecord.ts`(如缺失)
- [x] Task 12: 前端 XSS 防护审查
- [x] 12.1 全量检查 `views/project/**` 无 `v-html` 渲染后端文本;备注/说明/意见统一文本插值
- [x] 12.2 确认 axios 拦截器对 401 跳登录、403/422 统一提示,响应结构与 APP 兼容约定一致
## Task Dependencies
- Task 5、6 依赖后端 survey_order 既有接口(不阻塞,可与 Task 1-4 并行)
- Task 10 依赖 Task 1(安装派单后端先行)
- Task 12 依赖 Task 5-11(在页面重建完成后统一审查)
- Task 4 依赖 Task 1(install service 一并埋点)
+2
View File
@@ -18,6 +18,8 @@ export interface AcceptanceQuery {
pageSize?: number;
keyword?: string;
customerId?: number;
acceptStartDate?: string;
acceptEndDate?: string;
}
export interface AcceptanceListResult {
+4
View File
@@ -2,11 +2,13 @@ import request from '@/utils/request';
export interface ContractInfo {
id: number;
contractNo: string;
customerId: number;
xtContractNo?: string;
contractAmount?: number;
payCycle?: string;
salesmanId?: number;
salesmanName?: string;
signDate?: string;
contractDesc?: string;
startDate?: string;
@@ -24,6 +26,8 @@ export interface ContractQuery {
customerId?: number;
salesmanId?: number;
status?: number;
signStartDate?: string;
signEndDate?: string;
}
export interface ContractListResult {
+112
View File
@@ -0,0 +1,112 @@
import request from '@/utils/request';
// 安装派单状态:0已派发 1已完成
export const INSTALL_STATUS_OPTIONS = [
{ value: 0, label: '已派发' },
{ value: 1, label: '已完成' }
];
export const INSTALL_STATUS = {
DISPATCHED: 0,
COMPLETED: 1
};
// 关联客户精简字段(列表 decorate 附带)
export interface InstallOrderCustomer {
id: number;
customerNo?: string;
customerName?: string;
contactPerson?: string;
contactPhone?: string;
address?: string;
}
// 共享电工精简字段
export interface InstallOrderWorker {
id: number;
workerName?: string;
phone?: string;
workerTitle?: string;
}
// 关联方案精简字段
export interface InstallOrderPlan {
id: number;
planNo?: string;
planDesc?: string;
status?: number;
}
export interface InstallOrderInfo {
id: number;
installNo: string;
customerId: number;
installer?: string;
workerId?: number;
dispatchDate?: string;
startDate?: string;
endDate?: string;
planId: number;
remark?: string;
status: number;
created_at?: string;
updated_at?: string;
// 关联信息(列表/详情 decorate 附带)
customer?: InstallOrderCustomer;
worker?: InstallOrderWorker;
plan?: InstallOrderPlan;
customerName?: string;
workerName?: string;
planNo?: string;
}
export interface InstallOrderQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
status?: number;
// 派单日期范围,逗号分隔字符串 [startDate,endDate],后端 split 解析
dateRange?: string;
}
export interface InstallOrderListResult {
list: InstallOrderInfo[];
total: number;
page: number;
pageSize: number;
}
export interface InstallOrderCreateParams {
customerId: number;
installer?: string;
workerId?: number;
dispatchDate?: string;
startDate?: string;
endDate?: string;
planId: number;
remark?: string;
status?: number;
}
export type InstallOrderUpdateParams = Partial<InstallOrderCreateParams>;
export const installOrderApi = {
getList: (params: InstallOrderQuery) =>
request.get<InstallOrderListResult>('/install-orders', { params }),
getDetail: (id: number) =>
request.get<InstallOrderInfo>(`/install-orders/${id}`),
create: (data: InstallOrderCreateParams) =>
request.post<InstallOrderInfo>('/install-orders', data),
update: (id: number, data: InstallOrderUpdateParams) =>
request.put<InstallOrderInfo>(`/install-orders/${id}`, data),
// 标记完成:0已派发 → 1已完成
complete: (id: number) =>
request.put<InstallOrderInfo>(`/install-orders/${id}/complete`),
delete: (id: number) => request.delete(`/install-orders/${id}`)
};
+2
View File
@@ -29,6 +29,8 @@ export interface ProjectPlanQuery {
keyword?: string;
customerId?: number;
status?: number;
registerStartDate?: string;
registerEndDate?: string;
}
export interface ProjectPlanListResult {
+17
View File
@@ -9,6 +9,13 @@ export interface UploadImageResult {
size: number;
}
export interface UploadFileResult {
key: string;
url: string;
name: string;
size: number;
}
// 上传现场照片到对象存储(RustFS),返回对象 key 与预签名 URL
export const uploadApi = {
uploadImage: (file: File) => {
@@ -19,5 +26,15 @@ export const uploadApi = {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
});
},
// 上传方案/合同/验收等业务文件到对象存储,返回对象 key 与预签名 URL
uploadFile: (file: File) => {
const formData = new FormData();
formData.append('files', file);
return request.post<UploadFileResult>('/upload/file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
});
}
};
+3
View File
@@ -2,6 +2,7 @@ import request from '@/utils/request';
export interface VisitRecordInfo {
id: number;
visitNo?: string;
customerId: number;
contactPerson?: string;
contactPhone?: string;
@@ -17,6 +18,8 @@ export interface VisitRecordQuery {
pageSize?: number;
keyword?: string;
customerId?: number;
visitStartDate?: string;
visitEndDate?: string;
}
export interface VisitRecordListResult {
+6
View File
@@ -145,6 +145,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/project/visit/index.vue"),
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
},
{
path: "project/install",
name: "ProjectInstall",
component: () => import("@/views/project/install/index.vue"),
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
},
{
path: "dispatch",
name: "Dispatch",
+655 -144
View File
@@ -1,25 +1,68 @@
<template>
<div class="page-management">
<a-card title="项目验收管理">
<template #extra>
<a-space>
<div class="acceptance-page">
<!-- 顶部标题 + 台账操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>项目管理</span>
<span class="sep">/</span>
<span class="cur">项目验收台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">项目验收台账</span>
<span class="sub-title">ACCEPTANCE LEDGER</span>
</div>
<p class="desc">登记项目验收意见与验收文件,关联施工合同,跟踪验收人、安装人与验收结论全流程。</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增验收
</a-button>
</a-space>
</template>
</div>
</div>
</div>
<div class="search-bar">
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item filter-item-search">
<label class="filter-label">模糊搜索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="验收单号/验收人"
placeholder="输入验收单号、验收人、安装人..."
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item filter-item-date">
<label class="filter-label">验收日期范围</label>
<a-range-picker
v-model:value="acceptRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</div>
<div class="filter-item filter-item-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
@@ -29,37 +72,79 @@
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">验收数据台账</span>
<span class="divider">|</span>
<span class="range-text">
显示第 <b>{{ rangeStart }}</b> 到 <b>{{ rangeEnd }}</b> 笔,共
<b class="strong">{{ total }}</b> 笔验收记录
</span>
</div>
<div class="list-extra">
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'customerName'">
{{ getCustomerName(record.customerId) }}
<template v-if="column.key === 'acceptNo'">
<span class="mono">{{ record.acceptNo }}</span>
</template>
<template v-if="column.key === 'acceptDate'">
{{ record.acceptDate || '-' }}
<template v-else-if="column.key === 'customerName'">
<div class="cust-name">{{ getCustomerName(record.customerId) }}</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该验收记录吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
<template v-else-if="column.key === 'acceptor'">
{{ record.acceptor || '—' }}
</template>
<template v-else-if="column.key === 'acceptDate'">
<div class="plan-time">{{ record.acceptDate || '—' }}</div>
</template>
<template v-else-if="column.key === 'installer'">
{{ record.installer || '—' }}
</template>
<template v-else-if="column.key === 'acceptOpinion'">
<div v-if="record.acceptOpinion" class="sub-line">{{ record.acceptOpinion }}</div>
<div v-else>—</div>
</template>
<template v-else-if="column.key === 'contractName'">
<span class="mono-sub">{{ getContractNo(record.contractId) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<div class="row-actions">
<a-button type="primary" size="small" @click="handleEdit(record)">编辑</a-button>
<a-dropdown trigger="click">
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay>
<a-menu @click="({ key }) => handleMore(key as string, record)">
<a-menu-item key="delete" danger>删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</template>
@@ -68,13 +153,32 @@
</template>
</a-table>
</div>
</a-card>
<!-- 表尾分页 -->
<div class="list-footer">
<span class="range-text">
共 <b class="strong">{{ total }}</b> 条
</span>
<a-pagination
v-model:current="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:show-size-changer="true"
:show-quick-jumper="true"
:page-size-options="['10', '20', '50']"
:show-total="(t: number) => `共 ${t} 条`"
@change="handlePageChange"
/>
</div>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="620px"
width="660px"
@ok="handleSubmit"
@cancel="handleCancel"
>
@@ -103,9 +207,37 @@
<a-form-item label="安装人" name="installer">
<a-input v-model:value="formData.installer" placeholder="请输入安装人" maxlength="64" />
</a-form-item>
<a-form-item label="关联合同" name="contractId">
<a-select
v-model:value="formData.contractId"
placeholder="请选择关联合同"
:options="contractOptions"
show-search
option-filter-prop="label"
allow-clear
/>
</a-form-item>
<a-form-item label="验收意见" name="acceptOpinion">
<a-textarea v-model:value="formData.acceptOpinion" :rows="3" placeholder="请输入验收意见" maxlength="2000" />
</a-form-item>
<a-form-item label="验收文件" name="acceptFileUrl">
<a-upload
:show-upload-list="false"
:custom-request="handleFileUpload"
:before-upload="beforeFileUpload"
:disabled="uploading"
>
<a-button :loading="uploading">
<template #icon><UploadOutlined /></template>
{{ uploading ? '上传中...' : '点击上传验收文件' }}
</a-button>
</a-upload>
<div v-if="formData.acceptFileUrl" class="file-link">
<PaperClipOutlined />
<a :href="formData.acceptFileUrl" target="_blank">{{ fileName }}</a>
<a-button type="link" size="small" danger @click="clearFile">移除</a-button>
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
@@ -113,94 +245,125 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DownOutlined,
UploadOutlined,
PaperClipOutlined,
} from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import { acceptanceApi, type AcceptanceInfo, type AcceptanceQuery } from '@/api/acceptance';
import { customerApi } from '@/api/customer';
import { contractApi, type ContractInfo } from '@/api/contract';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { uploadApi } from '@/api/upload';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<AcceptanceInfo[]>([]);
const total = ref(0);
const searchForm = reactive<AcceptanceQuery>({ page: 1, pageSize: 10, keyword: '' });
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const formData = reactive({
const searchForm = reactive<AcceptanceQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as number | undefined,
acceptor: '',
acceptDate: undefined as any,
installer: '',
acceptOpinion: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
};
const acceptRange = ref<any>(undefined);
const columns = [
{ title: '验收单号', dataIndex: 'acceptNo', key: 'acceptNo', ellipsis: true, width: 170 },
{ title: '所属客户', key: 'customerName', ellipsis: true },
{ title: '验收人', dataIndex: 'acceptor', key: 'acceptor', ellipsis: true },
{ title: '验收时间', key: 'acceptDate', width: 110 },
{ title: '安装人', dataIndex: 'installer', key: 'installer', ellipsis: true },
{ title: '验收意见', dataIndex: 'acceptOpinion', key: 'acceptOpinion', ellipsis: true },
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
{ title: '验收单号', key: 'acceptNo', width: 170 },
{ title: '客户', key: 'customerName', width: 180 },
{ title: '验收人', key: 'acceptor', width: 110 },
{ title: '验收日期', key: 'acceptDate', width: 120 },
{ title: '安装人', key: 'installer', width: 110 },
{ title: '验收意见', key: 'acceptOpinion', ellipsis: true },
{ title: '关联合同', key: 'contractName', width: 150 },
{ title: '操作', key: 'action', align: 'center' as const, width: 170, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
const rangeStart = computed(() => (total.value === 0 ? 0 : (searchForm.page! - 1) * searchForm.pageSize! + 1));
const rangeEnd = computed(() => Math.min(searchForm.page! * searchForm.pageSize!, total.value));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
// ==================== 下拉选项 ====================
const customerOptions = ref<{ label: string; value: number }[]>([]);
const contractOptions = ref<{ label: string; value: number }[]>([]);
async function fetchCustomerOptions() {
try {
const res = await customerApi.getCustomerOptions();
customerOptions.value = (res.data || []).map((item: CustomerInfo) => ({
label: item.customerName,
value: item.id,
}));
} catch {
customerOptions.value = [];
}
}
async function fetchContractOptions() {
try {
const res = await contractApi.getList({ page: 1, pageSize: 999, status: 1 });
contractOptions.value = (res.data.list || []).map((item: ContractInfo) => ({
label: item.contractNo,
value: item.id,
}));
} catch {
contractOptions.value = [];
}
}
function getCustomerName(customerId?: number): string {
if (!customerId) return '—';
const found = customerOptions.value.find((c) => c.value === customerId);
return found ? found.label : `#${customerId}`;
}
function getContractNo(contractId?: number): string {
if (!contractId) return '—';
const found = contractOptions.value.find((c) => c.value === contractId);
return found ? found.label : `#${contractId}`;
}
// ==================== 数据请求 ====================
function buildQuery(): AcceptanceQuery {
const range = acceptRange.value as any[] | undefined;
return {
page: searchForm.page,
pageSize: searchForm.pageSize,
keyword: searchForm.keyword || undefined,
customerId: searchForm.customerId,
acceptStartDate: range?.[0] ? range[0].format('YYYY-MM-DD') : undefined,
acceptEndDate: range?.[1] ? range[1].format('YYYY-MM-DD') : undefined,
};
}
async function fetchList() {
loading.value = true;
try {
const res: any = await acceptanceApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
const res = await acceptanceApi.getList(buildQuery());
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
message.error('获取验收列表失败');
dataSource.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
customerOptions.value = res.data.map((item: any) => ({ label: item.customerName, value: item.id }));
}
} catch {
console.error('获取客户选项失败');
}
}
function getCustomerName(customerId: number): string {
const found = customerOptions.value.find((c) => c.value === customerId);
return found ? found.label : `#${customerId}`;
}
onMounted(() => {
fetchList();
fetchCustomerOptions();
fetchContractOptions();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
@@ -208,16 +371,56 @@ function handleSearch() {
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined;
acceptRange.value = undefined;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
function handlePageChange(page: number, pageSize: number) {
searchForm.page = page;
searchForm.pageSize = pageSize;
fetchList();
}
// ==================== 弹窗表单 ====================
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const uploading = ref(false);
const fileName = ref('');
const formData = reactive({
customerId: undefined as number | undefined,
acceptor: '',
acceptDate: undefined as any,
installer: '',
contractId: undefined as number | undefined,
acceptOpinion: '',
acceptFileUrl: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
};
function resetForm() {
formData.customerId = undefined;
formData.acceptor = '';
formData.acceptDate = undefined;
formData.installer = '';
formData.contractId = undefined;
formData.acceptOpinion = '';
formData.acceptFileUrl = '';
fileName.value = '';
if (formRef.value) {
formRef.value.resetFields();
}
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
@@ -232,24 +435,58 @@ function handleEdit(record: AcceptanceInfo) {
modalTitle.value = '编辑验收';
resetForm();
formData.customerId = record.customerId;
formData.acceptor = record.acceptor;
formData.acceptor = record.acceptor || '';
formData.acceptDate = record.acceptDate ? dayjs(record.acceptDate) : undefined;
formData.installer = record.installer;
formData.acceptOpinion = record.acceptOpinion;
formData.installer = record.installer || '';
formData.contractId = record.contractId;
formData.acceptOpinion = record.acceptOpinion || '';
formData.acceptFileUrl = record.acceptFileUrl || '';
fileName.value = deriveFileName(record.acceptFileUrl);
modalVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.acceptor = '';
formData.acceptDate = undefined;
formData.installer = '';
formData.acceptOpinion = '';
if (formRef.value) {
formRef.value.resetFields();
// ==================== 文件上传 ====================
function beforeFileUpload(file: File) {
const isLt20M = file.size / 1024 / 1024 < 20;
if (!isLt20M) {
message.error('文件大小不能超过 20MB');
return false;
}
return true;
}
async function handleFileUpload(options: any) {
const { file, onSuccess, onError } = options;
uploading.value = true;
try {
const res = await uploadApi.uploadFile(file as File);
formData.acceptFileUrl = res.data.url;
fileName.value = res.data.name || (file as File).name;
onSuccess?.(res.data);
message.success('上传成功');
} catch (e: any) {
message.error(e?.message || '文件上传失败');
onError?.(e);
} finally {
uploading.value = false;
}
}
function clearFile() {
formData.acceptFileUrl = '';
fileName.value = '';
}
function deriveFileName(url?: string): string {
if (!url) return '';
try {
return decodeURIComponent(url.split('/').pop() || url);
} catch {
return url;
}
}
// ==================== 提交 ====================
async function handleSubmit() {
try {
await formRef.value.validate();
@@ -259,30 +496,27 @@ async function handleSubmit() {
modalLoading.value = true;
try {
const submitData: any = {
const submitData = {
customerId: formData.customerId,
acceptor: formData.acceptor,
acceptor: formData.acceptor || undefined,
acceptDate: formData.acceptDate ? formData.acceptDate.format('YYYY-MM-DD') : undefined,
installer: formData.installer,
acceptOpinion: formData.acceptOpinion,
installer: formData.installer || undefined,
contractId: formData.contractId || undefined,
acceptOpinion: formData.acceptOpinion || undefined,
acceptFileUrl: formData.acceptFileUrl || undefined,
};
let res: any;
if (isEdit.value && editId.value) {
res = await acceptanceApi.update(editId.value, submitData);
await acceptanceApi.update(editId.value, submitData);
message.success('更新成功');
} else {
res = await acceptanceApi.create(submitData);
await acceptanceApi.create(submitData);
message.success('创建成功');
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch {
message.error('操作失败');
// 拦截器已提示
} finally {
modalLoading.value = false;
}
@@ -293,61 +527,338 @@ function handleCancel() {
resetForm();
}
async function handleDelete(id: number) {
// ==================== 行内操作 ====================
function handleMore(key: string, record: AcceptanceInfo) {
if (key === 'delete') return confirmDelete(record);
}
function confirmDelete(record: AcceptanceInfo) {
Modal.confirm({
title: '确认删除该验收记录?',
content: `验收单「${record.acceptNo}」将被删除,此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const res: any = await acceptanceApi.delete(id);
if (res.code === 200) {
await acceptanceApi.delete(record.id);
message.success('删除成功');
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch {
message.error('删除失败');
// 拦截器已提示
}
},
});
}
</script>
<style scoped>
.page-management {
.acceptance-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(220px, 2fr) 1fr 1.6fr auto;
gap: 12px 16px;
align-items: end;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 4px 12px;
flex-wrap: wrap;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .divider {
color: #cbd5e1;
}
.range-text {
font-size: 12px;
color: #5b6b7e;
}
.range-text b {
font-family: Consolas, Menlo, monospace;
color: #0d1c2c;
}
.range-text .strong {
color: #0054cd;
font-weight: 700;
}
.list-extra {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #5b6b7e;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 8px;
border-top: 1px solid #eef1f6;
}
.cust-name {
font-weight: 600;
color: #0d1c2c;
line-height: 1.4;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-weight: 700;
color: #0054cd;
}
.mono-sub {
font-family: Consolas, Menlo, monospace;
color: #1e293b;
}
.plan-time {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.sub-line {
font-size: 12.5px;
color: #5b6b7e;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
.file-link {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 13px;
color: #0054cd;
}
.file-link a {
color: #0054cd;
word-break: break-all;
}
:deep(.ant-table) {
font-size: 14px;
font-size: 13px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
</style>
File diff suppressed because it is too large. Load diff
+873
View File
@@ -0,0 +1,873 @@
<template>
<div class="install-page">
<!-- 顶部标题 + 台账操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>项目管理</span>
<span class="sep">/</span>
<span class="cur">安装派单台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">安装派单台账</span>
<span class="sub-title">INSTALL DISPATCH LEDGER</span>
</div>
<p class="desc">统筹项目方案落地施工派单,跟踪派单、施工起止与完工状态全流程。</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增安装派单
</a-button>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item filter-item-search">
<label class="filter-label">模糊搜索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="输入安装单号、安装人、备注..."
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">关联客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">当前状态</label>
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
:options="statusOptions"
/>
</div>
<div class="filter-item filter-item-date">
<label class="filter-label">派单日期范围</label>
<a-range-picker
v-model:value="searchForm.dateRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</div>
<div class="filter-item filter-item-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">安装派单数据台账</span>
<span class="divider">|</span>
<span class="range-text">
显示第 <b>{{ rangeStart }}</b> 到 <b>{{ rangeEnd }}</b> 笔,共
<b class="strong">{{ total }}</b> 笔派单记录
</span>
</div>
<div class="list-extra">
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'installNo'">
<span class="install-no">{{ record.installNo }}</span>
</template>
<template v-else-if="column.key === 'customer'">
<div class="cust-name">{{ record.customer?.customerName || '-' }}</div>
<div class="sub-line">{{ record.customer?.contactPhone || '—' }}</div>
</template>
<template v-else-if="column.key === 'plan'">
<div class="plan-no">{{ record.plan?.planNo || '-' }}</div>
<div class="sub-line">{{ record.plan?.planDesc || '—' }}</div>
</template>
<template v-else-if="column.key === 'installer'">
<div class="type-main">{{ record.installer || record.workerName || '-' }}</div>
</template>
<template v-else-if="column.key === 'dispatchDate'">
<div class="plan-time">{{ formatDate(record.dispatchDate) }}</div>
</template>
<template v-else-if="column.key === 'period'">
<div class="plan-time">
{{ formatDate(record.startDate) }} ~ {{ formatDate(record.endDate) }}
</div>
</template>
<template v-else-if="column.key === 'status'">
<span class="status-pill" :style="pillStyle(statusMeta(record.status))">
<i class="dot" :style="{ background: statusMeta(record.status).color }"></i>
{{ statusMeta(record.status).label }}
</span>
</template>
<template v-else-if="column.key === 'action'">
<div class="row-actions">
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button
v-if="record.status === INSTALL_STATUS.DISPATCHED"
type="link"
size="small"
@click="handleComplete(record)"
>标记完成</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</div>
</template>
</template>
<template #emptyText>
<a-empty description="暂无安装派单数据" />
</template>
</a-table>
</div>
<!-- 表尾:分页 -->
<div class="list-footer">
<a-pagination
v-model:current="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:show-size-changer="true"
:show-quick-jumper="true"
:page-size-options="['10', '20', '50']"
:show-total="(t: number) => `共 ${t} 条`"
@change="handlePageChange"
/>
</div>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="formMode === 'create' ? '新增安装派单' : '编辑安装派单'"
:confirm-loading="formLoading"
width="640px"
@ok="handleSubmit"
>
<a-form ref="formRef" :model="form" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
<a-form-item label="安装单号" v-if="formMode === 'edit'">
<span class="mono">{{ form.installNo }}</span>
</a-form-item>
<a-form-item label="关联客户" name="customerId">
<a-select
v-model:value="form.customerId"
placeholder="请选择客户"
show-search
option-filter-prop="label"
:options="customerOptions"
:disabled="formMode === 'edit'"
@change="onCustomerChange"
/>
</a-form-item>
<a-form-item label="关联方案" name="planId">
<a-select
v-model:value="form.planId"
placeholder="请选择方案"
show-search
option-filter-prop="label"
:options="planOptions"
:not-found-content="form.customerId ? '该客户暂无方案' : '请先选择客户'"
/>
</a-form-item>
<a-form-item label="安装人" name="installer">
<a-input v-model:value="form.installer" placeholder="请输入安装施工负责人" maxlength="64" />
</a-form-item>
<a-form-item label="派单日期" name="dispatchDate">
<a-date-picker
v-model:value="form.dispatchDate"
style="width: 100%"
placeholder="请选择派单日期"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="施工起止" name="period">
<a-range-picker
v-model:value="form.period"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" placeholder="选填,施工说明、注意事项等" :rows="3" maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import {
installOrderApi,
INSTALL_STATUS_OPTIONS,
INSTALL_STATUS,
type InstallOrderInfo,
type InstallOrderQuery,
} from '@/api/installOrder';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { projectPlanApi, type ProjectPlanInfo } from '@/api/projectPlan';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 元数据 ====================
const statusOptions = INSTALL_STATUS_OPTIONS;
// 状态配色:0已派发(琥珀) 1已完成(绿)
const STATUS_META: Record<number, { label: string; color: string; bg: string; border: string }> = {
0: { label: '已派发', color: '#d97706', bg: '#fef3c7', border: '#fde68a' },
1: { label: '已完成', color: '#059669', bg: '#d1fae5', border: '#a7f3d0' },
};
const statusMeta = (v: number) => STATUS_META[v] || STATUS_META[0];
const pillStyle = (m: { color: string; bg: string; border: string }) => ({
background: m.bg,
color: m.color,
borderColor: m.border,
});
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<InstallOrderInfo[]>([]);
const total = ref(0);
const searchForm = reactive({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as number | undefined,
status: undefined as number | undefined,
dateRange: undefined as any,
});
const columns = [
{ title: '安装单号', key: 'installNo', width: 170 },
{ title: '关联客户', key: 'customer', width: 200 },
{ title: '关联方案', key: 'plan', width: 180 },
{ title: '安装人', key: 'installer', width: 130 },
{ title: '派单日期', key: 'dispatchDate', width: 130, align: 'center' as const },
{ title: '施工起止', key: 'period', width: 220 },
{ title: '状态', key: 'status', width: 120, align: 'center' as const },
{ title: '操作', key: 'action', width: 200, align: 'center' as const, fixed: 'right' as const },
];
const rangeStart = computed(() => (total.value === 0 ? 0 : (searchForm.page - 1) * searchForm.pageSize + 1));
const rangeEnd = computed(() => Math.min(searchForm.page * searchForm.pageSize, total.value));
// ==================== 下拉选项 ====================
const customerOptions = ref<{ label: string; value: number }[]>([]);
const planOptions = ref<{ label: string; value: number }[]>([]);
async function fetchCustomerOptions() {
try {
const res = await customerApi.getCustomerOptions();
customerOptions.value = (res.data || []).map((item: CustomerInfo) => ({
label: item.customerName,
value: item.id,
}));
} catch {
customerOptions.value = [];
}
}
// 按客户拉取方案下拉(方案与客户一致性校验在后端)
async function fetchPlanOptions(customerId?: number) {
if (!customerId) {
planOptions.value = [];
return;
}
try {
const res = await projectPlanApi.getList({ page: 1, pageSize: 999, customerId });
planOptions.value = (res.data?.list || []).map((item: ProjectPlanInfo) => ({
label: item.planDesc ? `${item.planNo} · ${item.planDesc}` : item.planNo,
value: item.id,
}));
} catch {
planOptions.value = [];
}
}
// ==================== 数据请求 ====================
function buildQuery(): InstallOrderQuery {
const range = searchForm.dateRange as any[] | undefined;
let dateRange: string | undefined;
if (range && range.length === 2 && (range[0] || range[1])) {
const s = range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '';
const e = range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '';
if (s || e) dateRange = `${s},${e}`;
}
return {
page: searchForm.page,
pageSize: searchForm.pageSize,
keyword: searchForm.keyword || undefined,
customerId: searchForm.customerId,
status: searchForm.status,
dateRange,
};
}
async function fetchList() {
loading.value = true;
try {
const res = await installOrderApi.getList(buildQuery());
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
dataSource.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined;
searchForm.status = undefined;
searchForm.dateRange = undefined;
searchForm.page = 1;
fetchList();
}
function handlePageChange(page: number, pageSize: number) {
searchForm.page = page;
searchForm.pageSize = pageSize;
fetchList();
}
// ==================== 新建/编辑弹窗 ====================
const formVisible = ref(false);
const formLoading = ref(false);
const formMode = ref<'create' | 'edit'>('create');
const formRef = ref();
const editingId = ref<number | null>(null);
const form = reactive({
installNo: '',
customerId: undefined as number | undefined,
planId: undefined as number | undefined,
installer: '',
dispatchDate: undefined as any,
period: undefined as any,
remark: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择关联客户', trigger: 'change' }],
planId: [{ required: true, message: '请选择关联方案', trigger: 'change' }],
};
function resetForm() {
form.installNo = '';
form.customerId = undefined;
form.planId = undefined;
form.installer = '';
form.dispatchDate = undefined;
form.period = undefined;
form.remark = '';
planOptions.value = [];
}
function handleAdd() {
formMode.value = 'create';
editingId.value = null;
resetForm();
formVisible.value = true;
}
function handleEdit(record: InstallOrderInfo) {
formMode.value = 'edit';
editingId.value = record.id;
resetForm();
form.installNo = record.installNo;
form.customerId = record.customerId;
form.planId = record.planId;
form.installer = record.installer || '';
form.dispatchDate = record.dispatchDate ? dayjs(record.dispatchDate) : undefined;
form.period = (record.startDate || record.endDate)
? [record.startDate ? dayjs(record.startDate) : undefined, record.endDate ? dayjs(record.endDate) : undefined]
: undefined;
form.remark = record.remark || '';
fetchPlanOptions(record.customerId);
formVisible.value = true;
}
function onCustomerChange() {
form.planId = undefined;
fetchPlanOptions(form.customerId);
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
const period = form.period as any[] | undefined;
const payload = {
customerId: form.customerId!,
planId: form.planId!,
installer: form.installer || undefined,
dispatchDate: form.dispatchDate ? dayjs(form.dispatchDate).format('YYYY-MM-DD') : undefined,
startDate: period && period[0] ? dayjs(period[0]).format('YYYY-MM-DD') : undefined,
endDate: period && period[1] ? dayjs(period[1]).format('YYYY-MM-DD') : undefined,
remark: form.remark || undefined,
};
formLoading.value = true;
try {
if (formMode.value === 'create') {
const res = await installOrderApi.create(payload);
message.success(res.message || '创建成功');
} else if (editingId.value !== null) {
const res = await installOrderApi.update(editingId.value, payload);
message.success(res.message || '更新成功');
}
formVisible.value = false;
fetchList();
} catch {
// 拦截器已提示
} finally {
formLoading.value = false;
}
}
// ==================== 行内操作 ====================
function handleComplete(record: InstallOrderInfo) {
Modal.confirm({
title: '确认标记完成?',
content: `安装单 ${record.installNo} 将变更为「已完成」状态,完成后不可再修改。`,
okText: '确认完成',
cancelText: '取消',
async onOk() {
try {
const res = await installOrderApi.complete(record.id);
message.success(res.message || '已标记完成');
fetchList();
} catch {
// 拦截器已提示
}
},
});
}
function handleDelete(record: InstallOrderInfo) {
Modal.confirm({
title: '确认删除该安装派单?',
content: `安装单 ${record.installNo} 删除后不可恢复,请谨慎操作。`,
okText: '确认删除',
okType: 'danger',
cancelText: '返回',
async onOk() {
try {
const res = await installOrderApi.delete(record.id);
message.success(res.message || '删除成功');
fetchList();
} catch {
// 拦截器已提示
}
},
});
}
// ==================== 展示辅助 ====================
function formatDate(value?: string) {
if (!value) return '—';
return dayjs(value).format('YYYY-MM-DD');
}
</script>
<style scoped>
.install-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(220px, 2fr) 1fr 1fr 1.6fr auto;
gap: 12px 16px;
align-items: end;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 4px 12px;
flex-wrap: wrap;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .divider {
color: #cbd5e1;
}
.range-text {
font-size: 12px;
color: #5b6b7e;
}
.range-text b {
font-family: Consolas, Menlo, monospace;
color: #0d1c2c;
}
.range-text .strong {
color: #0054cd;
font-weight: 700;
}
.list-extra {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #5b6b7e;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 8px;
border-top: 1px solid #eef1f6;
}
.install-no {
font-family: Consolas, Menlo, monospace;
font-weight: 700;
color: #0054cd;
}
.cust-name {
font-weight: 600;
color: #0d1c2c;
line-height: 1.4;
}
.type-main {
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
.plan-no {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.sub-line {
margin-top: 3px;
font-size: 11.5px;
color: #8a97a8;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.plan-time {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 9999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.status-pill .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
.mono {
font-family: Consolas, Menlo, monospace;
color: #0054cd;
font-weight: 600;
}
:deep(.ant-table) {
font-size: 13px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
</style>
File diff suppressed because it is too large. Load diff
+12
View File
@@ -208,6 +208,18 @@
</button>
</div>
</div>
<div class="field span-2">
<label class="field-label"><span>备注</span></label>
<a-textarea
v-model:value="formState.remark"
:rows="3"
:maxlength="500"
show-count
placeholder="派单备注说明,如现场特殊要求、安全交底等"
:disabled="isReadonly"
/>
</div>
</div>
</section>
File diff suppressed because it is too large. Load diff
+523 -160
View File
@@ -1,25 +1,68 @@
<template>
<div class="page-management">
<a-card title="回访记录管理">
<template #extra>
<a-space>
<div class="visit-page">
<!-- 顶部标题 + 台账操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>项目管理</span>
<span class="sep">/</span>
<span class="cur">回访记录台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">回访记录台账</span>
<span class="sub-title">VISIT RECORD LEDGER</span>
</div>
<p class="desc">登记项目交付后的客户回访记录,跟踪回访人、回访时间与客户意见反馈全流程。</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增回访
</a-button>
</a-space>
</template>
</div>
</div>
</div>
<div class="search-bar">
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item filter-item-search">
<label class="filter-label">模糊搜索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="联系人/回访人"
placeholder="输入客户名称、回访人、联系人..."
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item filter-item-date">
<label class="filter-label">回访日期范围</label>
<a-range-picker
v-model:value="visitRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</div>
<div class="filter-item filter-item-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
@@ -29,37 +72,68 @@
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">回访数据台账</span>
<span class="divider">|</span>
<span class="range-text">
显示第 <b>{{ rangeStart }}</b> 到 <b>{{ rangeEnd }}</b> 笔,共
<b class="strong">{{ total }}</b> 笔回访记录
</span>
</div>
<div class="list-extra">
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'customerName'">
{{ getCustomerName(record.customerId) }}
<div class="cust-name">{{ getCustomerName(record.customerId) }}</div>
<div class="sub-line">{{ record.contactPerson || '—' }}{{ record.contactPhone ? ' · ' + record.contactPhone : '' }}</div>
</template>
<template v-if="column.key === 'visitTime'">
{{ record.visitTime || '-' }}
<template v-else-if="column.key === 'visitor'">
{{ record.visitor || '—' }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该回访记录吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
<template v-else-if="column.key === 'visitTime'">
<div class="plan-time">{{ record.visitTime || '—' }}</div>
</template>
<template v-else-if="column.key === 'customerOpinion'">
<div v-if="record.customerOpinion" class="sub-line">{{ record.customerOpinion }}</div>
<div v-else>—</div>
</template>
<template v-else-if="column.key === 'action'">
<div class="row-actions">
<a-button type="primary" size="small" @click="handleEdit(record)">编辑</a-button>
<a-dropdown trigger="click">
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay>
<a-menu @click="({ key }) => handleMore(key as string, record)">
<a-menu-item key="delete" danger>删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</template>
@@ -68,8 +142,27 @@
</template>
</a-table>
</div>
</a-card>
<!-- 表尾分页 -->
<div class="list-footer">
<span class="range-text">
共 <b class="strong">{{ total }}</b> 条
</span>
<a-pagination
v-model:current="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:show-size-changer="true"
:show-quick-jumper="true"
:page-size-options="['10', '20', '50']"
:show-total="(t: number) => `共 ${t} 条`"
@change="handlePageChange"
/>
</div>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
@@ -107,7 +200,7 @@
<a-date-picker v-model:value="formData.visitTime" style="width: 100%" placeholder="请选择回访时间" />
</a-form-item>
<a-form-item label="客户意见" name="customerOpinion">
<a-textarea v-model:value="formData.customerOpinion" :rows="3" placeholder="请输入客户意见" maxlength="2000" />
<a-textarea v-model:value="formData.customerOpinion" :rows="3" placeholder="请输入客户意见反馈" maxlength="2000" />
</a-form-item>
</a-form>
</a-modal>
@@ -116,26 +209,119 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DownOutlined } from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import { visitRecordApi, type VisitRecordInfo, type VisitRecordQuery } from '@/api/visitRecord';
import { customerApi } from '@/api/customer';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<VisitRecordInfo[]>([]);
const total = ref(0);
const searchForm = reactive<VisitRecordQuery>({ page: 1, pageSize: 10, keyword: '' });
const searchForm = reactive<VisitRecordQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as number | undefined,
});
const visitRange = ref<any>(undefined);
const columns = [
{ title: '客户', key: 'customerName', width: 220 },
{ title: '回访人', key: 'visitor', width: 120 },
{ title: '回访时间', key: 'visitTime', width: 160 },
{ title: '回访内容摘要', key: 'customerOpinion', ellipsis: true },
{ title: '操作', key: 'action', align: 'center' as const, width: 170, fixed: 'right' as const },
];
const rangeStart = computed(() => (total.value === 0 ? 0 : (searchForm.page! - 1) * searchForm.pageSize! + 1));
const rangeEnd = computed(() => Math.min(searchForm.page! * searchForm.pageSize!, total.value));
// ==================== 下拉选项 ====================
const customerOptions = ref<{ label: string; value: number }[]>([]);
async function fetchCustomerOptions() {
try {
const res = await customerApi.getCustomerOptions();
customerOptions.value = (res.data || []).map((item: CustomerInfo) => ({
label: item.customerName,
value: item.id,
}));
} catch {
customerOptions.value = [];
}
}
function getCustomerName(customerId?: number): string {
if (!customerId) return '—';
const found = customerOptions.value.find((c) => c.value === customerId);
return found ? found.label : `#${customerId}`;
}
// ==================== 数据请求 ====================
function buildQuery(): VisitRecordQuery {
const range = visitRange.value as any[] | undefined;
return {
page: searchForm.page,
pageSize: searchForm.pageSize,
keyword: searchForm.keyword || undefined,
customerId: searchForm.customerId,
visitStartDate: range?.[0] ? range[0].format('YYYY-MM-DD') : undefined,
visitEndDate: range?.[1] ? range[1].format('YYYY-MM-DD') : undefined,
};
}
async function fetchList() {
loading.value = true;
try {
const res = await visitRecordApi.getList(buildQuery());
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
dataSource.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined;
visitRange.value = undefined;
searchForm.page = 1;
fetchList();
}
function handlePageChange(page: number, pageSize: number) {
searchForm.page = page;
searchForm.pageSize = pageSize;
fetchList();
}
// ==================== 弹窗表单 ====================
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const formData = reactive({
customerId: undefined as number | undefined,
@@ -151,76 +337,16 @@ const formRules = {
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
};
const columns = [
{ title: '所属客户', key: 'customerName', ellipsis: true },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', ellipsis: true },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', ellipsis: true },
{ title: '回访人', dataIndex: 'visitor', key: 'visitor', ellipsis: true },
{ title: '回访时间', key: 'visitTime', width: 110 },
{ title: '客户意见', dataIndex: 'customerOpinion', key: 'customerOpinion', ellipsis: true },
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await visitRecordApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
function resetForm() {
formData.customerId = undefined;
formData.contactPerson = '';
formData.contactPhone = '';
formData.visitor = '';
formData.visitTime = undefined;
formData.customerOpinion = '';
if (formRef.value) {
formRef.value.resetFields();
}
} catch {
message.error('获取回访记录失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
customerOptions.value = res.data.map((item: any) => ({ label: item.customerName, value: item.id }));
}
} catch {
console.error('获取客户选项失败');
}
}
function getCustomerName(customerId: number): string {
const found = customerOptions.value.find((c) => c.value === customerId);
return found ? found.label : `#${customerId}`;
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function handleAdd() {
@@ -237,26 +363,15 @@ function handleEdit(record: VisitRecordInfo) {
modalTitle.value = '编辑回访';
resetForm();
formData.customerId = record.customerId;
formData.contactPerson = record.contactPerson;
formData.contactPhone = record.contactPhone;
formData.visitor = record.visitor;
formData.contactPerson = record.contactPerson || '';
formData.contactPhone = record.contactPhone || '';
formData.visitor = record.visitor || '';
formData.visitTime = record.visitTime ? dayjs(record.visitTime) : undefined;
formData.customerOpinion = record.customerOpinion;
formData.customerOpinion = record.customerOpinion || '';
modalVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.contactPerson = '';
formData.contactPhone = '';
formData.visitor = '';
formData.visitTime = undefined;
formData.customerOpinion = '';
if (formRef.value) {
formRef.value.resetFields();
}
}
// ==================== 提交 ====================
async function handleSubmit() {
try {
await formRef.value.validate();
@@ -266,31 +381,26 @@ async function handleSubmit() {
modalLoading.value = true;
try {
const submitData: any = {
const submitData = {
customerId: formData.customerId,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
visitor: formData.visitor,
contactPerson: formData.contactPerson || undefined,
contactPhone: formData.contactPhone || undefined,
visitor: formData.visitor || undefined,
visitTime: formData.visitTime ? formData.visitTime.format('YYYY-MM-DD') : undefined,
customerOpinion: formData.customerOpinion,
customerOpinion: formData.customerOpinion || undefined,
};
let res: any;
if (isEdit.value && editId.value) {
res = await visitRecordApi.update(editId.value, submitData);
await visitRecordApi.update(editId.value, submitData);
message.success('更新成功');
} else {
res = await visitRecordApi.create(submitData);
await visitRecordApi.create(submitData);
message.success('创建成功');
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch {
message.error('操作失败');
// 拦截器已提示
} finally {
modalLoading.value = false;
}
@@ -301,61 +411,314 @@ function handleCancel() {
resetForm();
}
async function handleDelete(id: number) {
// ==================== 行内操作 ====================
function handleMore(key: string, record: VisitRecordInfo) {
if (key === 'delete') return confirmDelete(record);
}
function confirmDelete(record: VisitRecordInfo) {
Modal.confirm({
title: '确认删除该回访记录?',
content: `客户「${getCustomerName(record.customerId)}」的回访记录将被删除,此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const res: any = await visitRecordApi.delete(id);
if (res.code === 200) {
await visitRecordApi.delete(record.id);
message.success('删除成功');
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch {
message.error('删除失败');
// 拦截器已提示
}
},
});
}
</script>
<style scoped>
.page-management {
.visit-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(220px, 2fr) 1fr 1.6fr auto;
gap: 12px 16px;
align-items: end;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 4px 12px;
flex-wrap: wrap;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .divider {
color: #cbd5e1;
}
.range-text {
font-size: 12px;
color: #5b6b7e;
}
.range-text b {
font-family: Consolas, Menlo, monospace;
color: #0d1c2c;
}
.range-text .strong {
color: #0054cd;
font-weight: 700;
}
.list-extra {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #5b6b7e;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 8px;
border-top: 1px solid #eef1f6;
}
.cust-name {
font-weight: 600;
color: #0d1c2c;
line-height: 1.4;
}
.plan-time {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.sub-line {
margin-top: 3px;
font-size: 11.5px;
color: #8a97a8;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
:deep(.ant-table) {
font-size: 14px;
font-size: 13px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
</style>
+266 -43
View File
@@ -41,7 +41,7 @@
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.0":
"@babel/core@^7.23.0":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
@@ -275,14 +275,6 @@
"@babel/types" "^7.29.7"
debug "^4.3.1"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/types@7.23.0":
version "7.23.0"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
@@ -292,6 +284,14 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
version "3.6.1"
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
@@ -307,6 +307,116 @@
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
@@ -405,7 +515,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -418,6 +528,121 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@rollup/rollup-android-arm-eabi@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab"
integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==
"@rollup/rollup-android-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70"
integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==
"@rollup/rollup-darwin-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72"
integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==
"@rollup/rollup-darwin-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd"
integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==
"@rollup/rollup-freebsd-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283"
integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==
"@rollup/rollup-freebsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809"
integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==
"@rollup/rollup-linux-arm-gnueabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d"
integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==
"@rollup/rollup-linux-arm-musleabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964"
integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==
"@rollup/rollup-linux-arm64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b"
integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==
"@rollup/rollup-linux-arm64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d"
integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==
"@rollup/rollup-linux-loong64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4"
integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==
"@rollup/rollup-linux-loong64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244"
integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==
"@rollup/rollup-linux-ppc64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a"
integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==
"@rollup/rollup-linux-ppc64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4"
integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==
"@rollup/rollup-linux-riscv64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e"
integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==
"@rollup/rollup-linux-riscv64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d"
integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==
"@rollup/rollup-linux-s390x-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6"
integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==
"@rollup/rollup-linux-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5"
integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==
"@rollup/rollup-linux-x64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39"
integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==
"@rollup/rollup-openbsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77"
integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==
"@rollup/rollup-openharmony-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab"
integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==
"@rollup/rollup-win32-arm64-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4"
integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==
"@rollup/rollup-win32-ia32-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4"
integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==
"@rollup/rollup-win32-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
@@ -441,7 +666,7 @@
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.15.30":
"@types/node@^22.15.30":
version "22.19.19"
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
@@ -463,7 +688,7 @@
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.0.1":
"@typescript-eslint/parser@^7.0.1":
version "7.18.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
@@ -539,14 +764,14 @@
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@~1.11.1", "@volar/language-core@1.11.1":
"@volar/language-core@1.11.1", "@volar/language-core@~1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
dependencies:
"@volar/source-map" "1.11.1"
"@volar/source-map@~1.11.1", "@volar/source-map@1.11.1":
"@volar/source-map@1.11.1", "@volar/source-map@~1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
@@ -603,7 +828,7 @@
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@3.5.34":
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
@@ -611,7 +836,7 @@
"@vue/compiler-core" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/compiler-sfc@^3.5.18", "@vue/compiler-sfc@3.5.34":
"@vue/compiler-sfc@3.5.34", "@vue/compiler-sfc@^3.5.18":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
@@ -696,7 +921,7 @@
"@vue/reactivity" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/runtime-dom@^3.4.15", "@vue/runtime-dom@3.5.34":
"@vue/runtime-dom@3.5.34", "@vue/runtime-dom@^3.4.15":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
@@ -714,7 +939,7 @@
"@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/shared@^3.3.0", "@vue/shared@^3.5.18", "@vue/shared@3.5.34":
"@vue/shared@3.5.34", "@vue/shared@^3.3.0", "@vue/shared@^3.5.18":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
@@ -729,7 +954,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.9.0:
acorn@^8.14.0, acorn@^8.9.0:
version "8.16.0"
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
@@ -902,7 +1127,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0, browserslist@^4.28.2, "browserslist@>= 4.21.0":
browserslist@^4.24.0, browserslist@^4.28.2:
version "4.28.2"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
@@ -1056,7 +1281,7 @@ de-indent@^1.0.2:
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
version "4.4.3"
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1116,7 +1341,7 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
echarts@^6.0.0, echarts@^6.1.0:
echarts@^6.1.0:
version "6.1.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
@@ -1234,7 +1459,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, eslint@>=6.0.0:
eslint@^8.56.0:
version "8.57.1"
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
@@ -1416,6 +1641,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
@@ -1651,7 +1881,7 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jiti@^1.21.7, jiti@>=1.21.0:
jiti@^1.21.7:
version "1.21.7"
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
@@ -1705,7 +1935,7 @@ kolorist@^1.8.0:
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
less@*, less@^4.6.4:
less@^4.6.4:
version "4.6.4"
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
@@ -1837,14 +2067,7 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimatch@^9.0.3:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.2"
minimatch@^9.0.4:
minimatch@^9.0.3, minimatch@^9.0.4:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
@@ -2015,7 +2238,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
"picomatch@^3 || ^4", picomatch@^4.0.4:
picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
@@ -2085,7 +2308,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14, postcss@>=8.0.9:
postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14:
version "8.5.15"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
@@ -2416,16 +2639,16 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
@@ -2438,7 +2661,7 @@ type-fest@^0.20.2:
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@*, typescript@>=4.2.0, typescript@>=4.5.0, typescript@~5.3.3, typescript@5.x:
typescript@~5.3.3:
version "5.3.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
@@ -2508,7 +2731,7 @@ vite-plugin-vue-dev-locator@1.0.3:
kolorist "^1.8.0"
magic-string "^0.30.4"
"vite@^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0", "vite@^5.0.0 || ^6.0.0", vite@^5.0.12, vite@>=4.0.0:
vite@^5.0.12:
version "5.4.21"
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
@@ -2568,7 +2791,7 @@ vue-types@^3.0.0:
dependencies:
is-plain-object "3.0.1"
vue@^3.0.0, vue@^3.2.25, vue@^3.3.0, vue@^3.4.0, vue@^3.4.15, vue@^3.5.0, vue@^3.5.11, vue@>=3.0.1, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.34:
vue@^3.4.15:
version "3.5.34"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
+10 -8
View File
@@ -106,9 +106,10 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32)
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32 + 安装派单 33)
-- 项目域:客户拜访(customer:visit) / 项目勘察(project:survey) / 项目方案(project:plan)
-- 合同管理(project:contract) / 项目验收(project:acceptance) / 回访记录(project:visit)
-- 合同管理(project:contract) / 安装派单(project:install)
-- 项目验收(project:acceptance) / 回访记录(project:visit)
-- 客户域:空间拓扑与用电架构(customer:topology)
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
@@ -116,8 +117,9 @@ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort
(27, 2, '项目勘察', '/project/survey', 'project/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 5, 2, 'project:acceptance',1),
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 6, 2, 'project:visit', 1),
(33, 2, '项目安装', '/project/install', 'project/install/index', 'ToolOutlined', 5, 2, 'project:install', 1),
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
@@ -131,7 +133,7 @@ ON CONFLICT (id) DO UPDATE SET
visible = EXCLUDED.visible;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 32, true);
SELECT setval('sys_menu_id_seq', 33, true);
-- ------------------------------------------------------------
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
@@ -186,16 +188,16 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 角色-菜单绑定:ADMIN 拥有全部 31 条菜单,SALESMAN 拥有客户域+项目+统计报表
-- 6. 角色-菜单绑定:ADMIN 拥有全部 32 条菜单,SALESMAN 拥有客户域+项目+统计报表
-- WORKER、CUSTOMER 不绑定任何菜单
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32),
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33),
(2, 1), (2, 2), (2, 5),
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32)
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 移除已废弃的菜单(客户建筑/客户房间,id=25/26)及其角色绑定(幂等清理,确保每次执行都收敛)
+648 -3
View File
@@ -8,11 +8,14 @@
"name": "smart-grid-iot-server",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1131.0",
"@aws-sdk/s3-request-presigner": "^3.1131.0",
"@midwayjs/cache-manager": "^3.19.0",
"@midwayjs/core": "^3.19.0",
"@midwayjs/koa": "^3.19.0",
"@midwayjs/logger": "^4.0.1",
"@midwayjs/sequelize": "^3.19.0",
"@midwayjs/upload": "^3.20.0",
"@midwayjs/validate": "^3.19.0",
"@types/validator": "^13.15.10",
"bcryptjs": "^2.4.3",
@@ -48,6 +51,331 @@
"node": ">=18"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1141.0.tgz",
"integrity": "sha512-HHHcP3gvLqeY9GPptpxLvA6VwDu+MWKCoaGGm0UtSUBh1hC7xJ9nNHoybgFfstC4lrj+k8oD6a1rWbGBWKYdag==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1623,6 +1951,19 @@
"node": ">=12"
}
},
"node_modules/@midwayjs/upload": {
"version": "3.20.24",
"resolved": "https://registry.npmjs.org/@midwayjs/upload/-/upload-3.20.24.tgz",
"integrity": "sha512-DOjmQ7sCHvzkyebZjWAEq6fp7LvyQXsfNp3zQs5CA8a2d64OQpJbgso6iXeZVB2OwkLV592Wuq0UfHXXhvgXsA==",
"license": "MIT",
"dependencies": {
"file-type": "16.5.4",
"raw-body": "2.5.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@midwayjs/validate": {
"version": "3.20.24",
"resolved": "https://registry.npmmirror.com/@midwayjs/validate/-/validate-3.20.24.tgz",
@@ -1752,6 +2093,93 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@smithy/core": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmmirror.com/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -2490,6 +2918,18 @@
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
@@ -2795,6 +3235,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
@@ -2827,6 +3287,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2907,6 +3373,30 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -3835,6 +4325,24 @@
"node": ">=4"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz",
@@ -3936,6 +4444,23 @@
"bser": "2.1.1"
}
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"license": "MIT",
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz",
@@ -4390,6 +4915,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
@@ -6323,6 +6868,19 @@
"node": ">=8"
}
},
"node_modules/peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz",
@@ -6533,6 +7091,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz",
@@ -6614,7 +7181,6 @@
"version": "2.5.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -6630,7 +7196,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
@@ -6647,7 +7212,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6660,6 +7224,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@@ -7190,6 +7786,15 @@
"node": ">= 0.6"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz",
@@ -7265,6 +7870,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superagent": {
"version": "8.1.2",
"resolved": "https://registry.npmmirror.com/superagent/-/superagent-8.1.2.tgz",
@@ -7371,6 +7993,23 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/toposort-class": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toposort-class/-/toposort-class-1.0.1.tgz",
@@ -7507,6 +8146,12 @@
"json5": "lib/cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/tsscmp/-/tsscmp-1.0.6.tgz",
@@ -0,0 +1,16 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InstallOrderService } from "../service/install_order.service";
@Controller("/api/install-orders")
export class InstallOrderController {
@Inject() ctx!: Context;
@Inject() installOrderService!: InstallOrderService;
@Get("/") async list(@Query() query: any) { return this.installOrderService.list(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.installOrderService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.installOrderService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.installOrderService.update(Number(id), body, this.ctx); }
@Put("/:id/complete") async complete(@Param("id") id: string) { return this.installOrderService.complete(Number(id), this.ctx); }
@Del("/:id") async delete(@Param("id") id: string) { return this.installOrderService.delete(Number(id), this.ctx); }
}
+51
View File
@@ -0,0 +1,51 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsInt,
IsArray,
IsDateString,
IsIn,
ArrayNotEmpty,
Matches,
Min,
Max,
MaxLength,
ArrayMaxSize,
ArrayMinSize
} from "class-validator";
export class InstallOrderQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
// 派单日期范围 [startDate, endDate],YYYY-MM-DD
@IsOptional() @IsArray() @ArrayMinSize(2) @ArrayMaxSize(2) @IsDateString({}, { each: true }) dateRange?: string[];
}
export class InstallOrderCreateDTO {
@IsNotEmpty({ message: "所属客户不能为空" }) @IsInt() customerId!: number;
@IsNotEmpty({ message: "关联方案不能为空" }) @IsInt() planId!: number;
@IsNotEmpty({ message: "安装人不能为空" }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) installer!: string;
@IsNotEmpty({ message: "派单日期不能为空" }) @IsDateString() dispatchDate!: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsDateString() startDate?: string;
@IsOptional() @IsDateString() endDate?: string;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
}
export class InstallOrderUpdateDTO {
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() planId?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) installer?: string;
@IsOptional() @IsDateString() dispatchDate?: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsDateString() startDate?: string;
@IsOptional() @IsDateString() endDate?: string;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
}
+66
View File
@@ -0,0 +1,66 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import Worker from "./worker.entity";
import ProjectPlan from "./project_plan.entity";
@Table({
tableName: "install_order",
comment: "安装派单信息表",
timestamps: false,
indexes: [
{ name: "idx_install_customer", fields: ["customer_id"] },
{ name: "idx_install_worker", fields: ["worker_id"] },
{ name: "idx_install_plan", fields: ["plan_id"] },
{ name: "idx_install_status", fields: ["status"] },
{ name: "idx_install_dispatch_date", fields: ["dispatch_date"] }
]
})
export default class InstallOrder extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "install_no", comment: "安装单号(唯一)" })
installNo!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@Column({ type: DataType.STRING(64), comment: "安装人" })
installer!: string;
@ForeignKey(() => Worker)
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
workerId!: number;
@Column({ type: DataType.DATEONLY, field: "dispatch_date", comment: "派单日期" })
dispatchDate!: string;
@Column({ type: DataType.DATEONLY, field: "start_date", comment: "开始日期" })
startDate!: string;
@Column({ type: DataType.DATEONLY, field: "end_date", comment: "结束日期" })
endDate!: string;
@ForeignKey(() => ProjectPlan)
@Column({ type: DataType.BIGINT, allowNull: false, field: "plan_id", comment: "方案ID" })
planId!: number;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已完成" })
status!: number;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
worker!: Worker;
@BelongsTo(() => ProjectPlan, { foreignKey: "planId", as: "plan" })
plan!: ProjectPlan;
@CreatedAt
created_at!: Date;
}
+1
View File
@@ -23,6 +23,7 @@ const PERMISSION_MAP: Record<string, string> = {
'/api/survey-orders': 'project:survey',
'/api/project-plans': 'project:plan',
'/api/contracts': 'project:contract',
'/api/install-orders': 'project:install',
'/api/acceptances': 'project:acceptance',
'/api/visit-records': 'project:visit',
};
+19 -2
View File
@@ -1,4 +1,4 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
@@ -7,6 +7,7 @@ import Acceptance from "../entity/acceptance.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
@Provide()
export class AcceptanceService {
@@ -22,6 +23,9 @@ export class AcceptanceService {
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -57,9 +61,18 @@ export class AcceptanceService {
const where: any = {};
if (query.keyword) {
where[Op.or] = [{ acceptor: { [Op.like]: `%${query.keyword}%` } }, { acceptOpinion: { [Op.like]: `%${query.keyword}%` } }];
where[Op.or] = [
{ acceptNo: { [Op.like]: `%${query.keyword}%` } },
{ acceptor: { [Op.like]: `%${query.keyword}%` } },
{ acceptOpinion: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.acceptStartDate || query.acceptEndDate) {
where.acceptDate = {};
if (query.acceptStartDate) where.acceptDate[Op.gte] = query.acceptStartDate;
if (query.acceptEndDate) where.acceptDate[Op.lte] = query.acceptEndDate;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
@@ -88,6 +101,7 @@ export class AcceptanceService {
try {
data.acceptNo = await this.generateAcceptNo();
const created = await this.acceptModel.create(data);
await this.logService.logAction(ctx, "项目验收", "创建", data.acceptNo, `创建项目验收 ${data.acceptNo}`);
return { code: 200, message: "创建项目验收成功", data: created.toJSON() };
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
@@ -108,6 +122,7 @@ export class AcceptanceService {
}
delete data.acceptNo;
await record.update(data);
await this.logService.logAction(ctx, "项目验收", "更新", record.acceptNo, `更新项目验收 ${record.acceptNo}`);
return { code: 200, message: "更新项目验收成功", data: record.toJSON() };
}
@@ -115,7 +130,9 @@ export class AcceptanceService {
const record = await this.acceptModel.findByPk(id);
if (!record) return { code: 404, message: "项目验收不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该验收", data: null };
const acceptNo = record.acceptNo;
await record.destroy();
await this.logService.logAction(ctx, "项目验收", "删除", acceptNo, `删除项目验收 ${acceptNo}`);
return { code: 200, message: "删除项目验收成功", data: null };
}
}
+19 -2
View File
@@ -1,4 +1,4 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
@@ -7,6 +7,7 @@ import Contract from "../entity/contract.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
@Provide()
export class ContractService {
@@ -22,6 +23,9 @@ export class ContractService {
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -72,11 +76,20 @@ export class ContractService {
const where: any = {};
if (query.keyword) {
where[Op.or] = [{ xtContractNo: { [Op.like]: `%${query.keyword}%` } }, { contractDesc: { [Op.like]: `%${query.keyword}%` } }];
where[Op.or] = [
{ contractNo: { [Op.like]: `%${query.keyword}%` } },
{ xtContractNo: { [Op.like]: `%${query.keyword}%` } },
{ contractDesc: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.salesmanId) where.salesmanId = query.salesmanId;
if (query.status !== undefined) where.status = query.status;
if (query.signStartDate || query.signEndDate) {
where.signDate = {};
if (query.signStartDate) where.signDate[Op.gte] = query.signStartDate;
if (query.signEndDate) where.signDate[Op.lte] = query.signEndDate;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
@@ -109,6 +122,7 @@ export class ContractService {
data.contractNo = await this.generateContractNo();
if (!data.xtContractNo) data.xtContractNo = await this.generateXtContractNo();
const created = await this.contractModel.create(data);
await this.logService.logAction(ctx, "项目合同", "创建", data.contractNo, `创建项目合同 ${data.contractNo}`);
return { code: 200, message: "创建项目合同成功", data: created.toJSON() };
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
@@ -131,6 +145,7 @@ export class ContractService {
}
delete data.contractNo;
await record.update(data);
await this.logService.logAction(ctx, "项目合同", "更新", record.contractNo, `更新项目合同 ${record.contractNo}`);
return { code: 200, message: "更新项目合同成功", data: record.toJSON() };
}
@@ -138,7 +153,9 @@ export class ContractService {
const record = await this.contractModel.findByPk(id);
if (!record) return { code: 404, message: "项目合同不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该合同", data: null };
const contractNo = record.contractNo;
await record.destroy();
await this.logService.logAction(ctx, "项目合同", "删除", contractNo, `删除项目合同 ${contractNo}`);
return { code: 200, message: "删除项目合同成功", data: null };
}
+279
View File
@@ -0,0 +1,279 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context } from "@midwayjs/koa";
import InstallOrder from "../entity/install_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import ProjectPlan from "../entity/project_plan.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
// 安装派单状态:0已派发 1已完成
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
@Provide()
export class InstallOrderService {
@InjectRepository(InstallOrder)
installModel!: Repository<InstallOrder>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(ProjectPlan)
planModel!: Repository<ProjectPlan>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
const customer = await this.customerModel.findByPk(customerId);
return !!customer && customer.salesmanId === userId;
}
// 工单可见性:管理员 / 客户归属业务员
private async isOrderVisible(userId: number, order: InstallOrder): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
return this.isCustomerOwner(userId, order.customerId);
}
// 单号规则:WH-INST-YYYY-NNNN(年内累计序号),冲突重试 1 次
private async generateInstallNo(retry: number = 0): Promise<string> {
const now = new Date();
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
const year = d.getUTCFullYear();
const prefix = `WH-INST-${year}-`;
const last = await this.installModel.findOne({ where: { installNo: { [Op.like]: `${prefix}%` } }, order: [["install_no", "DESC"]] });
let seq = 1;
if (last) seq = (parseInt(last.installNo.slice(-4), 10) || 0) + 1;
const result = `${prefix}${String(seq).padStart(4, "0")}`;
const exists = await this.installModel.findOne({ where: { installNo: result } });
if (exists) {
if (retry < 1) return this.generateInstallNo(retry + 1);
throw new Error("安装单号生成冲突,请重试");
}
return result;
}
// query 参数 → Sequelize where(关键字/客户/状态/派单日期范围)
private buildWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ installNo: { [Op.like]: `%${query.keyword}%` } },
{ installer: { [Op.like]: `%${query.keyword}%` } },
{ remark: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
// dateRange: [startDate, endDate],作用于 dispatch_date
const range = Array.isArray(query.dateRange) ? query.dateRange : (typeof query.dateRange === "string" ? query.dateRange.split(",") : null);
if (range && range.length === 2 && (range[0] || range[1])) {
const cond: any = {};
if (range[0]) cond[Op.gte] = range[0];
if (range[1]) cond[Op.lte] = range[1];
where.dispatchDate = cond;
}
return where;
}
// 行级隔离:非管理员仅可见「客户归属自己」的工单
private async scopeWhere(baseWhere: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const where: any = { ...baseWhere };
if (await this.isAdmin(userId)) return where;
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
where.customerId = ids.length ? { [Op.in]: ids } : -1;
return where;
}
// 批量补充客户/电工/方案信息,避免 N+1
private async decorate(rows: any[]): Promise<any[]> {
if (rows.length === 0) return rows;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const workerIds = [...new Set(rows.map((r) => r.workerId).filter(Boolean))];
const planIds = [...new Set(rows.map((r) => r.planId).filter(Boolean))];
const [customers, workers, plans] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address"]
})
: Promise.resolve([]),
workerIds.length
? this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } },
attributes: ["id", "workerName", "phone", "workerTitle"]
})
: Promise.resolve([]),
planIds.length
? this.planModel.findAll({
where: { id: { [Op.in]: planIds } },
attributes: ["id", "planNo", "planDesc", "status"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const workerMap = new Map(workers.map((w) => [w.id, w]));
const planMap = new Map(plans.map((p) => [p.id, p]));
for (const row of rows) {
const customer: any = customerMap.get(row.customerId);
const worker: any = workerMap.get(row.workerId);
const plan: any = planMap.get(row.planId);
row.customer = customer ? customer.toJSON() : null;
row.customerName = customer?.customerName || "";
row.worker = worker ? worker.toJSON() : null;
row.workerName = worker?.workerName || "";
row.plan = plan ? plan.toJSON() : null;
row.planNo = plan?.planNo || "";
}
return rows;
}
// 统一清洗入参:空串转 null
private normalizePayload(data: any): any {
const payload: any = { ...data };
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildWhere(query), ctx);
const { count, rows } = await this.installModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]],
distinct: true
});
const list = await this.decorate(rows.map((r) => r.toJSON()));
return { code: 200, message: "获取安装派单列表成功", data: { list, total: count, page, pageSize } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.installModel.findByPk(id);
if (!record) return { code: 404, message: "安装派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权限访问该安装派单", data: null };
const vo: any = record.toJSON();
await this.decorate([vo]);
return { code: 200, message: "获取安装派单详情成功", data: vo };
}
async create(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建安装派单", data: null };
// 方案与客户一致性校验,防止串客户派单
const plan = await this.planModel.findByPk(Number(data.planId));
if (!plan) return { code: 400, message: "关联方案不存在", data: null };
if (Number(plan.customerId) !== Number(data.customerId)) return { code: 400, message: "方案与客户不匹配", data: null };
if (data.workerId) {
const worker = await this.workerModel.findByPk(Number(data.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
}
const payload = this.normalizePayload(data);
payload.installNo = await this.generateInstallNo();
if (payload.status === undefined || payload.status === null) payload.status = STATUS.DISPATCHED;
let created: InstallOrder;
try {
created = await this.installModel.create(payload);
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
payload.installNo = await this.generateInstallNo();
created = await this.installModel.create(payload);
} else {
throw err;
}
}
const vo: any = created.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "安装派单", "创建", created.installNo, `创建安装派单 ${created.installNo}`);
return { code: 200, message: "创建安装派单成功", data: vo };
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.installModel.findByPk(id);
if (!record) return { code: 404, message: "安装派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该安装派单", data: null };
if (record.status === STATUS.COMPLETED) return { code: 400, message: "已完成的派单不可修改", data: null };
delete data.installNo;
if (data.customerId !== undefined && Number(data.customerId) !== Number(record.customerId)) {
if (!(await this.isCustomerOwner(ctx.state.user.userId, Number(data.customerId)))) {
return { code: 403, message: "无权限转移到该客户", data: null };
}
}
if (data.planId !== undefined) {
const plan = await this.planModel.findByPk(Number(data.planId));
if (!plan) return { code: 400, message: "关联方案不存在", data: null };
const targetCustomerId = data.customerId !== undefined ? Number(data.customerId) : Number(record.customerId);
if (Number(plan.customerId) !== targetCustomerId) return { code: 400, message: "方案与客户不匹配", data: null };
}
const payload = this.normalizePayload(data);
await record.update(payload);
const fresh = await this.installModel.findByPk(id);
const vo: any = fresh!.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "安装派单", "更新", record.installNo, `更新安装派单 ${record.installNo}`);
return { code: 200, message: "更新安装派单成功", data: vo };
}
// 完成派单:仅允许 0已派发 → 1已完成
async complete(id: number, ctx: Context): Promise<any> {
const record = await this.installModel.findByPk(id);
if (!record) return { code: 404, message: "安装派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该安装派单", data: null };
if (record.status !== STATUS.DISPATCHED) return { code: 400, message: "仅已派发状态的派单可标记完成", data: null };
await record.update({ status: STATUS.COMPLETED });
const vo: any = record.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "安装派单", "完成", record.installNo, `完成安装派单 ${record.installNo}`);
return { code: 200, message: "安装派单已完成", data: vo };
}
async delete(id: number, ctx: Context): Promise<any> {
const record = await this.installModel.findByPk(id);
if (!record) return { code: 404, message: "安装派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该安装派单", data: null };
const installNo = record.installNo;
await record.destroy();
await this.logService.logAction(ctx, "安装派单", "删除", installNo, `删除安装派单 ${installNo}`);
return { code: 200, message: "删除安装派单成功", data: null };
}
}
+19
View File
@@ -1,5 +1,6 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Context } from "@midwayjs/koa";
import SysOperationLog from "../entity/sysOperationLog.entity";
import { Op } from "sequelize";
import dayjs from "dayjs";
@@ -9,6 +10,24 @@ export class LogService {
@InjectRepository(SysOperationLog)
logModel!: typeof SysOperationLog;
// 写入操作日志:操作人userId/模块/动作/目标单号/摘要
// method 与 ip 从 ctx 提取,targetNo+摘要 落 params(JSON) 字段
async logAction(ctx: Context, module: string, action: string, targetNo: string, summary: string): Promise<void> {
try {
const userId = (ctx.state as any)?.user?.userId ?? 0;
await this.logModel.create({
userId,
module,
action,
method: ctx.method || "",
params: JSON.stringify({ targetNo: targetNo || "", summary: summary || "" }),
ip: ctx.ip || ""
} as any);
} catch {
// 日志写入失败不影响主业务流程
}
}
async list(query: {
page?: number;
pageSize?: number;
+19 -2
View File
@@ -1,4 +1,4 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
@@ -8,6 +8,7 @@ import ProjectPlanDetail from "../entity/project_plan_detail.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
@Provide()
export class ProjectPlanService {
@@ -26,6 +27,9 @@ export class ProjectPlanService {
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -61,10 +65,19 @@ export class ProjectPlanService {
const where: any = {};
if (query.keyword) {
where[Op.or] = [{ registrar: { [Op.like]: `%${query.keyword}%` } }, { planDesc: { [Op.like]: `%${query.keyword}%` } }];
where[Op.or] = [
{ planNo: { [Op.like]: `%${query.keyword}%` } },
{ registrar: { [Op.like]: `%${query.keyword}%` } },
{ planDesc: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.status !== undefined) where.status = query.status;
if (query.registerStartDate || query.registerEndDate) {
where.registerTime = {};
if (query.registerStartDate) where.registerTime[Op.gte] = query.registerStartDate;
if (query.registerEndDate) where.registerTime[Op.lte] = query.registerEndDate;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
@@ -99,6 +112,7 @@ export class ProjectPlanService {
await this.detailModel.bulkCreate(details.map((d: any) => ({ ...d, planId: created.id, customerId: created.customerId })));
}
const result = await this.planModel.findByPk(created.id, { include: [this.detailModel] });
await this.logService.logAction(ctx, "项目方案", "创建", data.planNo, `创建项目方案 ${data.planNo}`);
return { code: 200, message: "创建项目方案成功", data: result?.toJSON() };
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
@@ -133,6 +147,7 @@ export class ProjectPlanService {
}
}
const result = await this.planModel.findByPk(id, { include: [this.detailModel] });
await this.logService.logAction(ctx, "项目方案", "更新", record.planNo, `更新项目方案 ${record.planNo}`);
return { code: 200, message: "更新项目方案成功", data: result?.toJSON() };
}
@@ -140,8 +155,10 @@ export class ProjectPlanService {
const record = await this.planModel.findByPk(id);
if (!record) return { code: 404, message: "项目方案不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该方案", data: null };
const planNo = record.planNo;
await this.detailModel.destroy({ where: { planId: id } });
await record.destroy();
await this.logService.logAction(ctx, "项目方案", "删除", planNo, `删除项目方案 ${planNo}`);
return { code: 200, message: "删除项目方案成功", data: null };
}
@@ -13,6 +13,7 @@ import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { UploadService } from "./upload.service";
import { LogService } from "./log.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
@@ -57,6 +58,9 @@ export class SurveyOrderService {
@Inject()
uploadService!: UploadService;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -479,6 +483,7 @@ export class SurveyOrderService {
await this.createSopResults(created.id, templateCode);
const vo = this.toVO(created);
await this.decorate([vo]);
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
return { code: 200, message: "创建勘察派单成功", data: vo };
}
@@ -501,6 +506,7 @@ export class SurveyOrderService {
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
@@ -535,6 +541,7 @@ export class SurveyOrderService {
const result = await this.assignCore(id, worker, dto.estimatedFee);
if (!result.ok) return { code: result.code, message: result.message, data: null };
await this.logService.logAction(ctx, "勘察派单", "指派", record.surveyNo, `指派勘察派单 ${record.surveyNo} 至 ${worker.workerName}`);
return { code: 200, message: "派单成功", data: result.data };
}
@@ -555,6 +562,7 @@ export class SurveyOrderService {
failIds.push(id);
}
}
await this.logService.logAction(ctx, "勘察派单", "批量指派", "", `批量指派 ${successCount} 条勘察派单至 ${worker.workerName},失败 ${failIds.length} 条`);
return {
code: 200,
message: `批量派单完成,成功 ${successCount} 条`,
@@ -580,6 +588,7 @@ export class SurveyOrderService {
await record.update(patch);
const vo = this.toVO(record);
await this.decorate([vo]);
await this.logService.logAction(ctx, "勘察派单", "复核", record.surveyNo, `复核勘察派单 ${record.surveyNo}: ${dto.result === "pass" ? "通过归档" : "退回勘察"}`);
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", data: vo };
}
@@ -594,6 +603,7 @@ export class SurveyOrderService {
const urgeCount = (record.urgeCount || 0) + 1;
const lastUrgedAt = new Date();
await record.update({ urgeCount, lastUrgedAt });
await this.logService.logAction(ctx, "勘察派单", "催办", record.surveyNo, `催办勘察派单 ${record.surveyNo}(第 ${urgeCount} 次)`);
return { code: 200, message: "催办成功", data: { urgeCount, lastUrgedAt } };
}
@@ -606,6 +616,7 @@ export class SurveyOrderService {
}
await record.update({ status: STATUS.CANCELED, cancelReason: dto.reason || null });
await this.logService.logAction(ctx, "勘察派单", "取消", record.surveyNo, `取消勘察派单 ${record.surveyNo}`);
return { code: 200, message: "取消工单成功", data: this.toVO(record) };
}
@@ -613,9 +624,11 @@ export class SurveyOrderService {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
const surveyNo = record.surveyNo;
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
}
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
@@ -7,6 +7,7 @@ import VisitRecord from "../entity/visit_record.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
@Provide()
export class VisitRecordService {
@@ -22,6 +23,9 @@ export class VisitRecordService {
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -65,6 +69,11 @@ export class VisitRecordService {
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.visitStartDate || query.visitEndDate) {
where.visitTime = {};
if (query.visitStartDate) where.visitTime[Op.gte] = query.visitStartDate;
if (query.visitEndDate) where.visitTime[Op.lte] = query.visitEndDate;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
@@ -93,6 +102,7 @@ export class VisitRecordService {
try {
data.visitNo = await this.generateVisitNo();
const created = await this.visitModel.create(data);
await this.logService.logAction(ctx, "客户回访", "创建", data.visitNo, `创建客户回访 ${data.visitNo}`);
return { code: 200, message: "创建客户回访成功", data: created.toJSON() };
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
@@ -113,6 +123,7 @@ export class VisitRecordService {
}
delete data.visitNo;
await record.update(data);
await this.logService.logAction(ctx, "客户回访", "更新", record.visitNo, `更新客户回访 ${record.visitNo}`);
return { code: 200, message: "更新客户回访成功", data: record.toJSON() };
}
@@ -120,7 +131,9 @@ export class VisitRecordService {
const record = await this.visitModel.findByPk(id);
if (!record) return { code: 404, message: "客户回访不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该回访记录", data: null };
const visitNo = record.visitNo;
await record.destroy();
await this.logService.logAction(ctx, "客户回访", "删除", visitNo, `删除客户回访 ${visitNo}`);
return { code: 200, message: "删除客户回访成功", data: null };
}
}
+79 -1421
View File
File diff suppressed because it is too large. Load diff