From 22d27a940d3f2f4aaa0d73aa44506448b13aab04 Mon Sep 17 00:00:00 2001
From: andyesfly <35127884@qq.com>
Date: Sat, 17 Oct 2026 10:08:42 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=8B=98=E5=AF=9F?=
=?UTF-8?q?=E4=B8=9A=E5=8A=A1=E4=B8=BA=E6=98=8E=E7=BB=86=E6=A8=A1=E5=BC=8F?=
=?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?=
=?UTF-8?q?=E4=BA=BA=E6=80=A7=E5=8C=96=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
---
client/.env.development | 2 +-
client/.env.production | 2 +-
client/index.html | 2 +-
client/src/api/job.ts | 38 +-
client/src/api/surveyOrder.ts | 64 +-
client/src/layouts/index.vue | 2 +-
client/src/router/index.ts | 2 +-
client/src/views/dispatch/survey/form.vue | 658 ++++++++++++------
client/src/views/dispatch/survey/index.vue | 99 +--
client/src/views/login/index.vue | 6 +-
client/src/views/system/job/index.vue | 374 +++++++++-
database/db_init.sql | 68 +-
database/db_seed.sql | 10 +-
.../migrations/20261012_job_freq_config.sql | 54 ++
.../20261015_survey_detail_replace_sop.sql | 44 ++
server/src/controller/job.controller.ts | 8 +-
.../src/controller/survey_order.controller.ts | 9 +-
.../src/controller/worker_app.controller.ts | 31 +-
server/src/dto/job.dto.ts | 23 +-
server/src/dto/survey_order.dto.ts | 6 +-
server/src/entity/survey_detail.entity.ts | 49 ++
server/src/entity/survey_sop_item.entity.ts | 40 --
server/src/entity/survey_sop_result.entity.ts | 45 --
server/src/entity/sys_job.entity.ts | 14 +
server/src/service/job.service.ts | 77 +-
server/src/service/job_scheduler.service.ts | 15 +
server/src/service/survey_order.service.ts | 172 ++---
server/src/service/worker_app.service.ts | 58 +-
server/src/util/cron_schedule.ts | 512 ++++++++++++++
29 files changed, 1908 insertions(+), 576 deletions(-)
create mode 100644 database/migrations/20261012_job_freq_config.sql
create mode 100644 database/migrations/20261015_survey_detail_replace_sop.sql
create mode 100644 server/src/entity/survey_detail.entity.ts
delete mode 100644 server/src/entity/survey_sop_item.entity.ts
delete mode 100644 server/src/entity/survey_sop_result.entity.ts
create mode 100644 server/src/util/cron_schedule.ts
diff --git a/client/.env.development b/client/.env.development
index f49820f..acdec06 100644
--- a/client/.env.development
+++ b/client/.env.development
@@ -1,3 +1,3 @@
# 开发环境变量
VITE_API_BASE_URL=/api
-VITE_APP_TITLE=智能物联网设备管理系统
+VITE_APP_TITLE=鑫潼智电管理系统
diff --git a/client/.env.production b/client/.env.production
index b5123c0..627d4d0 100644
--- a/client/.env.production
+++ b/client/.env.production
@@ -1,3 +1,3 @@
# 生产环境变量
VITE_API_BASE_URL=/api
-VITE_APP_TITLE=智能物联网设备管理系统
+VITE_APP_TITLE=鑫潼智电管理系统
diff --git a/client/index.html b/client/index.html
index 122e3ea..adb5ed6 100644
--- a/client/index.html
+++ b/client/index.html
@@ -4,7 +4,7 @@
-
My Trae Project
+ 鑫潼智电管理系统
@@ -1338,107 +1550,178 @@ onMounted(async () => {
background: #0054cd;
}
-/* ---------- 第 2 步:SOP ---------- */
-.tmpl-box {
+/* ---------- 第 2 步:勘察明细 ---------- */
+.head-actions {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 12px;
flex-shrink: 0;
}
-.tmpl-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 4px 12px;
- border: none;
- border-radius: 9999px;
- background: rgba(113, 42, 226, 0.1);
- color: #712ae2;
- font-size: 12px;
- font-weight: 700;
- cursor: pointer;
- transition: background 0.2s;
-}
-.tmpl-btn:hover {
- background: rgba(113, 42, 226, 0.2);
-}
-.sop-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
+.detail-body {
+ display: flex;
+ flex-direction: column;
gap: 12px;
padding: 18px 20px 20px;
}
-.sop-grid.is-empty {
- grid-template-columns: 1fr;
+.detail-empty {
+ padding: 24px;
+ text-align: center;
+ font-size: 13px;
+ color: #5b6b7e;
+ background: #eef4ff;
+ border-radius: 10px;
}
-.sop-card {
+.detail-rows {
display: flex;
flex-direction: column;
- justify-content: space-between;
- gap: 8px;
- padding: 12px;
- border: 1px solid rgba(219, 228, 238, 0.5);
- border-radius: 8px;
- background: #eef4ff;
+ gap: 12px;
}
-.sop-top {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
+.detail-row {
+ border: 1px solid #dbe4ee;
+ border-radius: 10px;
+ background: #f8fafc;
+ overflow: hidden;
}
-.sop-name {
- font-size: 13px;
- font-weight: 700;
- color: #0d1c2c;
- line-height: 1.5;
-}
-.sop-required {
- flex-shrink: 0;
- padding: 2px 8px;
- border-radius: 4px;
- background: rgba(220, 38, 38, 0.1);
- color: #dc2626;
- font-size: 11px;
- font-weight: 700;
-}
-.sop-desc {
- font-size: 12px;
- line-height: 1.7;
- color: #5b6b7e;
-}
-.sop-foot {
+.detail-row-head {
display: flex;
align-items: center;
justify-content: space-between;
- padding-top: 8px;
- border-top: 1px solid rgba(219, 228, 238, 0.5);
- font-family: 'JetBrains Mono', Consolas, monospace;
+ gap: 8px;
+ padding: 8px 14px;
+ background: #eef4ff;
+ border-bottom: 1px solid #e2eaf5;
+}
+.detail-row-idx {
font-size: 12px;
font-weight: 700;
color: #0054cd;
}
-.sop-add {
- min-height: 96px;
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+ padding: 14px;
+}
+.dfield {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+.dfield.span-all {
+ grid-column: 1 / -1;
+}
+.dfield-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #0d1c2c;
+}
+.dfield-label .opt {
+ font-size: 11px;
+ font-weight: 400;
+ color: #8a97a8;
+}
+.detail-photos {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+.detail-photos :deep(.ant-image) {
+ display: block;
+}
+.detail-photos :deep(.ant-image img) {
+ object-fit: cover;
+ border-radius: 6px;
+ border: 1px solid #dbe4ee;
+}
+.photo-thumb {
+ position: relative;
+}
+.photo-del {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: rgba(13, 28, 44, 0.72);
+ color: #fff;
+ font-size: 13px;
+ line-height: 1;
+ cursor: pointer;
+ z-index: 2;
+ transition: background 0.2s;
+}
+.photo-del:hover {
+ background: #dc2626;
+}
+.photo-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 6px;
+ gap: 4px;
+ width: 76px;
+ height: 57px;
border: 1px dashed #c2c6d8;
- border-radius: 8px;
+ border-radius: 6px;
+ background: #fff;
color: #5b6b7e;
- font-size: 13px;
+ font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
-.sop-add:hover {
+.photo-add:hover:not(:disabled) {
border-color: rgba(0, 84, 205, 0.5);
color: #0054cd;
}
-.sop-add-icon {
- font-size: 22px;
+.photo-add:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+/* 只读态明细 */
+.detail-ro-row {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 14px;
+ border: 1px solid #dbe4ee;
+ border-radius: 10px;
+ background: #f8fafc;
+}
+.detail-ro-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.detail-ro-scope {
+ font-size: 13px;
+ font-weight: 700;
+ color: #0d1c2c;
+}
+.detail-ro-photos {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.detail-ro-photos :deep(.ant-image img) {
+ object-fit: cover;
+ border-radius: 8px;
+}
+.detail-ro-remark {
+ font-size: 12px;
+ line-height: 1.7;
+ color: #5b6b7e;
+ white-space: pre-wrap;
}
/* ---------- 第 3 步:电工与报酬 ---------- */
@@ -1719,61 +2002,6 @@ onMounted(async () => {
background: #f8fafc;
border-radius: 10px;
}
-.ro-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.ro-sop-row {
- display: grid;
- grid-template-columns: 20px 1fr 88px 1.2fr;
- align-items: center;
- gap: 10px;
- padding: 8px 12px;
- border-radius: 8px;
- background: #f8fafc;
- font-size: 12.5px;
- color: #0d1c2c;
-}
-.ro-sop-check {
- width: 16px;
- height: 16px;
- border: 1px solid #dbe4ee;
- border-radius: 4px;
- background: #fff;
- color: #fff;
- font-size: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.ro-sop-check.done {
- background: #00685f;
- border-color: #00685f;
-}
-.ro-sop-name {
- font-weight: 700;
-}
-.ro-sop-tag {
- font-size: 11.5px;
- font-weight: 700;
- color: #712ae2;
-}
-.ro-sop-value {
- font-size: 11.5px;
- color: #5b6b7e;
- word-break: break-all;
-}
-.ro-sop-thumbs {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-.ro-sop-thumb :deep(img) {
- object-fit: cover;
- border-radius: 6px;
- border: 1px solid #dbe4ee;
-}
.ro-photo-list {
display: flex;
flex-wrap: wrap;
@@ -1868,7 +2096,7 @@ onMounted(async () => {
.field-grid {
grid-template-columns: 1fr;
}
- .sop-grid {
+ .detail-grid {
grid-template-columns: 1fr;
}
.cand-grid {
diff --git a/client/src/views/dispatch/survey/index.vue b/client/src/views/dispatch/survey/index.vue
index f3f03bf..92ff56d 100644
--- a/client/src/views/dispatch/survey/index.vue
+++ b/client/src/views/dispatch/survey/index.vue
@@ -514,29 +514,31 @@
—
-
-
- 勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
-
-
-
-
{{ row.itemName || row.sopItem?.itemName || '检查项' }}
-
{{ row.checked ? '已回执' : '未回执' }}
+
+
勘察明细({{ detailRecord.details.length }} 条)
+
+
+
+ 明细 {{ idx + 1 }}
+
+ {{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
+ · {{ row.roomName }}
+
+
+
+
-
-
- {{ row.receiptValue.join('、') }}
-
-
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
// ==================== 详情 / 编辑 ====================
const detailVisible = ref(false);
const detailRecord = ref
(null);
-const detailSopDone = computed(
- () => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
-);
async function openDetail(record: SurveyOrderInfo) {
detailRecord.value = record;
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
return `${s} ~ ${e}`;
}
-const sopChipStyle = (checked?: boolean) =>
- chipStyle(
- checked
- ? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
- : { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
- );
+// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
+function floorLabel(floorNo?: string | null) {
+ if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
+ const n = Number(floorNo);
+ if (Number.isNaN(n)) return String(floorNo);
+ return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
+}
function money(value?: number | string) {
if (value == null || value === '') return '—';
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
gap: 10px;
}
-.sop-item {
- padding: 10px 0;
- border-bottom: 1px dashed #eef1f6;
+.detail-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
-.sop-item:last-child {
- border-bottom: none;
- padding-bottom: 0;
+.detail-row {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid #eef1f6;
+ border-radius: 8px;
+ background: #f8fafc;
}
-.sop-item-head {
+.detail-row-head {
display: flex;
align-items: center;
- gap: 8px;
- margin-bottom: 6px;
+ gap: 10px;
+ flex-wrap: wrap;
}
-.sop-item-name {
+.detail-row-idx {
+ font-size: 12px;
+ font-weight: 600;
+ color: #0054cd;
+}
+
+.detail-row-scope {
font-size: 13px;
font-weight: 600;
color: #0d1c2c;
}
-.sop-item-remark {
- margin-top: 6px;
+.detail-row-remark {
font-size: 12px;
+ line-height: 1.7;
color: #8a97a8;
+ white-space: pre-wrap;
}
/* AntD 覆盖 */
diff --git a/client/src/views/login/index.vue b/client/src/views/login/index.vue
index 6ce5e42..795985b 100644
--- a/client/src/views/login/index.vue
+++ b/client/src/views/login/index.vue
@@ -11,8 +11,8 @@
- 智能电网设备管理系统
- Smart Grid IoT Management System
+ 鑫潼智电管理系统
+ XINTONG SMART POWER MANAGEMENT SYSTEM
@@ -59,7 +59,7 @@
-
© 2026 智能电网设备管理系统 All Rights Reserved
+
© 2026 鑫潼智电管理系统 All Rights Reserved
diff --git a/client/src/views/system/job/index.vue b/client/src/views/system/job/index.vue
index 1d4974d..ae973d8 100644
--- a/client/src/views/system/job/index.vue
+++ b/client/src/views/system/job/index.vue
@@ -53,6 +53,12 @@
{{ record.jobName }}
未注册
+
+
+ {{ record.cronDescription || record.cronExpression }}
+ {{ record.cronExpression }}
+
+
{{ formatParams(record.params) }}
@@ -111,7 +117,7 @@
title="编辑任务"
:confirm-loading="saving"
:mask-closable="false"
- width="640px"
+ width="720px"
@ok="handleSave"
@cancel="handleCancelEdit"
>
@@ -125,9 +131,110 @@
{{ currentJob?.jobName || '-' }}
-
-
+
+ 执行频率
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 表达式
+ {{ preview.cronExpression || '-' }}
+
+
+ 说明
+ {{ preview.description || '-' }}
+
+
+
下次执行
+
-
+
+ - {{ formatDateTime(time) }}
+
+
+
+
+
+
+
+ 任务参数
+
+
+ 失败重试与备注
+
@@ -228,7 +338,7 @@
diff --git a/database/db_init.sql b/database/db_init.sql
index 6f15e2e..0b3abe6 100644
--- a/database/db_init.sql
+++ b/database/db_init.sql
@@ -748,56 +748,36 @@ CREATE INDEX idx_survey_plan_start ON survey_order (plan_start);
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
--- 21.1 勘察 SOP 模板检查项表
-DROP TABLE IF EXISTS survey_sop_item CASCADE;
-CREATE TABLE survey_sop_item (
- id BIGSERIAL PRIMARY KEY,
- template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
- sort_order INTEGER NOT NULL DEFAULT 0,
- item_name VARCHAR(128) NOT NULL,
- item_desc VARCHAR(255),
- receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
- receipt_require VARCHAR(64),
- required BOOLEAN NOT NULL DEFAULT TRUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
-COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
-COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
-COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
-COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
-COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
-COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
-COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
-COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
-COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
-CREATE UNIQUE INDEX uk_sop_template_order ON survey_sop_item (template_code, sort_order);
-
--- 21.2 勘察 SOP 工单回执表
-DROP TABLE IF EXISTS survey_sop_result CASCADE;
-CREATE TABLE survey_sop_result (
+-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
+DROP TABLE IF EXISTS survey_detail CASCADE;
+CREATE TABLE survey_detail (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
- sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
- checked BOOLEAN NOT NULL DEFAULT FALSE,
- receipt_value VARCHAR(1024),
+ building_id BIGINT,
+ building_name VARCHAR(128),
+ floor_no VARCHAR(16),
+ room_id BIGINT,
+ room_name VARCHAR(128),
+ photos TEXT,
remark VARCHAR(255),
- checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
-COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
-COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
-COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
-COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
-COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
-COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
-COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
-COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
-COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
-CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
-CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
+COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
+COMMENT ON COLUMN survey_detail.id IS '自增主键';
+COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
+COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
+COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
+COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
+COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
+COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
+COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
+COMMENT ON COLUMN survey_detail.remark IS '备注';
+COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
+COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
+CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
+CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- 22. 安装派单表
DROP TABLE IF EXISTS install_order CASCADE;
diff --git a/database/db_seed.sql b/database/db_seed.sql
index a5f83ce..1579ca8 100644
--- a/database/db_seed.sql
+++ b/database/db_seed.sql
@@ -333,10 +333,10 @@ SELECT setval('sys_menu_id_seq', 60, true);
-- ------------------------------------------------------------
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
-- ------------------------------------------------------------
-INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
-('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
+INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
+('demo_heartbeat', '示例心跳', '*/5 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
-('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
+('log_clean', '日志清理', '0 3 * * *', 'day', '{"times":["03:00"]}'::jsonb, '{"keepDays":90}'::jsonb, 2, 60, 1,
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
ON CONFLICT (job_key) DO NOTHING;
@@ -370,8 +370,8 @@ SELECT setval('sys_menu_id_seq', 57, true);
-- ------------------------------------------------------------
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
-- ------------------------------------------------------------
-INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
-('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
+INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
+('notice_scan', '通知规则扫描', '0 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
ON CONFLICT (job_key) DO NOTHING;
diff --git a/database/migrations/20261012_job_freq_config.sql b/database/migrations/20261012_job_freq_config.sql
new file mode 100644
index 0000000..a601b71
--- /dev/null
+++ b/database/migrations/20261012_job_freq_config.sql
@@ -0,0 +1,54 @@
+-- 定时任务人性化频率配置
+-- 背景:定时任务配置目前只有裸 cron 输入框,不直观。本次为 sys_job 增加
+-- freq_type / freq_config 结构化字段,cron_expression 仍作为调度唯一真值,
+-- freq_type / freq_config 仅用于配置界面回填与展示(不参与调度判断)。
+-- 幂等:列用 ADD COLUMN IF NOT EXISTS;存量回填按 job_key 且仅在该行仍是默认值时更新。
+BEGIN;
+
+-- ------------------------------------------------------------
+-- 1) sys_job 新增频率配置字段
+-- ------------------------------------------------------------
+ALTER TABLE sys_job
+ ADD COLUMN IF NOT EXISTS freq_type VARCHAR(16) NOT NULL DEFAULT 'custom', -- 频率类型: minute/hour/day/week/month/custom
+ ADD COLUMN IF NOT EXISTS freq_config JSONB NOT NULL DEFAULT '{}'::jsonb; -- 频率配置(人性化配置表单回填用)
+
+COMMENT ON COLUMN sys_job.freq_type IS '频率类型: minute每N分钟 hour每N小时 day每天 week每周 month每月 custom自定义';
+COMMENT ON COLUMN sys_job.freq_config IS '频率配置(JSONB,仅用于配置界面回填与展示,不参与调度判断)';
+
+-- ------------------------------------------------------------
+-- 2) 存量任务回填
+-- 仅在仍是默认值(custom + 空配置)时更新,避免覆盖运维已在页面上的修改;
+-- 未命中的行保持 custom,配置界面会以「自定义」展示原 cron 表达式。
+-- ------------------------------------------------------------
+UPDATE sys_job
+SET freq_type = 'minute', freq_config = '{"interval":5}'::jsonb
+WHERE job_key = 'demo_heartbeat' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
+ AND cron_expression = '*/5 * * * *';
+
+UPDATE sys_job
+SET freq_type = 'day', freq_config = '{"times":["03:00"]}'::jsonb
+WHERE job_key = 'log_clean' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
+ AND cron_expression IN ('0 3 * * *', '0 0 3 * * *');
+
+UPDATE sys_job
+SET freq_type = 'day', freq_config = '{"times":["08:00"]}'::jsonb
+WHERE job_key = 'notice_scan' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
+ AND cron_expression IN ('0 8 * * *', '0 0 8 * * *');
+
+-- ------------------------------------------------------------
+-- 3) cron 段数归一:notice_scan 原为 6 段带秒表达式,秒=0 时与 5 段等价,
+-- 统一为 5 段便于配置界面回填(行为不变,仍为每天 08:00)
+-- ------------------------------------------------------------
+UPDATE sys_job
+SET cron_expression = '0 8 * * *'
+WHERE job_key = 'notice_scan' AND cron_expression = '0 0 8 * * *';
+
+-- ------------------------------------------------------------
+-- 4) 兜底:自定义类型的 freq_config 同步为当前表达式,保证配置界面可回填
+-- (应用侧每次保存都会同时写入两者,此处仅覆盖历史数据)
+-- ------------------------------------------------------------
+UPDATE sys_job
+SET freq_config = jsonb_build_object('expression', cron_expression)
+WHERE freq_type = 'custom';
+
+COMMIT;
\ No newline at end of file
diff --git a/database/migrations/20261015_survey_detail_replace_sop.sql b/database/migrations/20261015_survey_detail_replace_sop.sql
new file mode 100644
index 0000000..7755c12
--- /dev/null
+++ b/database/migrations/20261015_survey_detail_replace_sop.sql
@@ -0,0 +1,44 @@
+-- 勘察改造:移除 SOP(勘察技术要点与回执标准),新增「勘察明细」survey_detail
+-- 语义:勘察明细按「客户空间拓扑」登记,一条明细 = 建筑 + 楼层 + 房间(可空) + 现场照片 + 备注。
+-- 幂等:CREATE TABLE IF NOT EXISTS / DROP TABLE IF EXISTS / 触发器先 DROP 后建,可重复执行。
+BEGIN;
+
+-- 1. 新增勘察明细表
+CREATE TABLE IF NOT EXISTS survey_detail (
+ id BIGSERIAL PRIMARY KEY,
+ survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
+ building_id BIGINT,
+ building_name VARCHAR(128),
+ floor_no VARCHAR(16),
+ room_id BIGINT,
+ room_name VARCHAR(128),
+ photos TEXT,
+ remark VARCHAR(255),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
+COMMENT ON COLUMN survey_detail.id IS '自增主键';
+COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
+COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
+COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
+COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
+COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
+COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
+COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
+COMMENT ON COLUMN survey_detail.remark IS '备注';
+COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
+COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
+
+CREATE INDEX IF NOT EXISTS idx_survey_detail_order ON survey_detail (survey_order_id);
+
+DROP TRIGGER IF EXISTS trg_survey_detail_updated_at ON survey_detail;
+CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
+
+-- 2. 移除 SOP 两张表(回执表先删,避免外键依赖)
+DROP TABLE IF EXISTS survey_sop_result CASCADE;
+DROP TABLE IF EXISTS survey_sop_item CASCADE;
+
+COMMIT;
diff --git a/server/src/controller/job.controller.ts b/server/src/controller/job.controller.ts
index 93a84fb..93ce43b 100644
--- a/server/src/controller/job.controller.ts
+++ b/server/src/controller/job.controller.ts
@@ -1,6 +1,6 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { JobService } from "../service/job.service";
-import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto";
+import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO, CronPreviewDTO } from "../dto/job.dto";
@Controller("/api/jobs")
export class JobController {
@@ -19,6 +19,12 @@ export class JobController {
return this.jobService.clearLogs(parsed);
}
+ /** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
+ @Post("/cron/preview")
+ async previewCron(@Body() dto: CronPreviewDTO) {
+ return this.jobService.previewCron(dto.freqType, dto.freqConfig);
+ }
+
@Put("/:id")
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
return this.jobService.update(Number(id), dto);
diff --git a/server/src/controller/survey_order.controller.ts b/server/src/controller/survey_order.controller.ts
index 6433b78..4e4e54d 100644
--- a/server/src/controller/survey_order.controller.ts
+++ b/server/src/controller/survey_order.controller.ts
@@ -11,9 +11,6 @@ export class SurveyOrderController {
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
- @Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
- return this.surveyOrderService.listSopItems(templateCode);
- }
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
// 批量指派
@@ -23,9 +20,9 @@ export class SurveyOrderController {
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
- // 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
- @Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
- return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
+ // 勘察明细:全量替换(新增/编辑/删除统一走此接口)
+ @Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
+ return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
}
// 指派 / 改派共享电工
diff --git a/server/src/controller/worker_app.controller.ts b/server/src/controller/worker_app.controller.ts
index 6bdb716..b390274 100644
--- a/server/src/controller/worker_app.controller.ts
+++ b/server/src/controller/worker_app.controller.ts
@@ -1,7 +1,9 @@
-import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
+import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query, Files } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
+import { UploadFileInfo } from "@midwayjs/upload";
import { WorkerAppService } from "../service/worker_app.service";
import { NoticeService } from "../service/notice.service";
+import { UploadService } from "../service/upload.service";
import {
WorkerRegisterDTO,
WorkerLoginDTO,
@@ -20,6 +22,24 @@ export class WorkerAppController {
@Inject()
noticeService!: NoticeService;
+ @Inject()
+ uploadService!: UploadService;
+
+ // 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
+ @Post("/upload/image")
+ async uploadImage(@Files() files: UploadFileInfo[]) {
+ const file = files && files[0];
+ if (!file) {
+ return { code: 400, message: "请选择要上传的图片", data: null };
+ }
+ try {
+ const data = await this.uploadService.uploadFile(file);
+ return { code: 200, message: "上传成功", data };
+ } catch (err: any) {
+ return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
+ }
+ }
+
// 注册(匿名)
@Post("/auth/register")
async register(@Body() body: WorkerRegisterDTO) {
@@ -130,6 +150,15 @@ export class WorkerAppController {
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
}
+ // 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片
+ @Put("/orders/:source/:id/details")
+ async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) {
+ if (source !== "survey") {
+ return { code: 400, message: "仅勘察订单支持维护明细" };
+ }
+ return this.workerAppService.saveSurveyDetails(this.currentWorkerId(), Number(id), body);
+ }
+
// 收益汇总
@Get("/earnings/summary")
async earningsSummary() {
diff --git a/server/src/dto/job.dto.ts b/server/src/dto/job.dto.ts
index 3a32719..d385c39 100644
--- a/server/src/dto/job.dto.ts
+++ b/server/src/dto/job.dto.ts
@@ -1,10 +1,15 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
+/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
export class UpdateJobDTO {
@IsString()
- @IsNotEmpty({ message: "cron 表达式不能为空" })
- @MaxLength(64, { message: "cron 表达式最大 64 字符" })
- cronExpression!: string;
+ @IsNotEmpty({ message: "频率类型不能为空" })
+ @MaxLength(16, { message: "频率类型非法" })
+ freqType!: string;
+
+ @IsOptional()
+ @IsObject()
+ freqConfig?: Record;
@IsOptional()
@IsObject()
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
remark?: string;
}
+/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
+export class CronPreviewDTO {
+ @IsString()
+ @IsNotEmpty({ message: "频率类型不能为空" })
+ @MaxLength(16, { message: "频率类型非法" })
+ freqType!: string;
+
+ @IsOptional()
+ @IsObject()
+ freqConfig?: Record;
+}
+
export class JobStatusDTO {
@IsInt()
@Min(0, { message: "状态值非法" })
diff --git a/server/src/dto/survey_order.dto.ts b/server/src/dto/survey_order.dto.ts
index 37bf86b..527ffe4 100644
--- a/server/src/dto/survey_order.dto.ts
+++ b/server/src/dto/survey_order.dto.ts
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
- // 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
- @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
+ // 勘察明细(建筑/楼层/房间 + 现场照片),非持久化列,仅用于创建时一并写入
+ @IsOptional() @IsArray() details?: any[];
}
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
- @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
+ @IsOptional() @IsArray() declare details?: any[];
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
diff --git a/server/src/entity/survey_detail.entity.ts b/server/src/entity/survey_detail.entity.ts
new file mode 100644
index 0000000..1521062
--- /dev/null
+++ b/server/src/entity/survey_detail.entity.ts
@@ -0,0 +1,49 @@
+import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
+import SurveyOrder from "./survey_order.entity";
+
+@Table({
+ tableName: "survey_detail",
+ comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
+ timestamps: true,
+ createdAt: "created_at",
+ updatedAt: "updated_at",
+ indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
+})
+export default class SurveyDetail extends Model {
+ @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
+ id!: number;
+
+ @ForeignKey(() => SurveyOrder)
+ @Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
+ surveyOrderId!: number;
+
+ @Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
+ buildingId!: number;
+
+ @Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
+ buildingName!: string;
+
+ @Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层:正数为地上、负数为地下,如 1 / -1" })
+ floorNo!: string;
+
+ @Column({ type: DataType.BIGINT, field: "room_id", comment: "房间ID(→ customer_room,可空)" })
+ roomId!: number;
+
+ @Column({ type: DataType.STRING(128), field: "room_name", comment: "房间名称(冗余,可空)" })
+ roomName!: string;
+
+ @Column({ type: DataType.TEXT, field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
+ photos!: string;
+
+ @Column({ type: DataType.STRING(255), comment: "备注" })
+ remark!: string;
+
+ @BelongsTo(() => SurveyOrder, { foreignKey: "surveyOrderId", as: "surveyOrder" })
+ surveyOrder!: SurveyOrder;
+
+ @CreatedAt
+ created_at!: Date;
+
+ @UpdatedAt
+ updated_at!: Date;
+}
diff --git a/server/src/entity/survey_sop_item.entity.ts b/server/src/entity/survey_sop_item.entity.ts
deleted file mode 100644
index 47c848c..0000000
--- a/server/src/entity/survey_sop_item.entity.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
-
-@Table({
- tableName: "survey_sop_item",
- comment: "勘察 SOP 模板检查项表",
- timestamps: true,
- createdAt: "created_at",
- updatedAt: false,
- indexes: [
- { name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
- ]
-})
-export default class SurveySopItem extends Model {
- @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
- id!: number;
-
- @Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
- templateCode!: string;
-
- @Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
- sortOrder!: number;
-
- @Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
- itemName!: string;
-
- @Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
- itemDesc!: string;
-
- @Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
- receiptType!: string;
-
- @Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
- receiptRequire!: string;
-
- @Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
- required!: boolean;
-
- @CreatedAt
- created_at!: Date;
-}
diff --git a/server/src/entity/survey_sop_result.entity.ts b/server/src/entity/survey_sop_result.entity.ts
deleted file mode 100644
index adcb689..0000000
--- a/server/src/entity/survey_sop_result.entity.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
-import SurveyOrder from "./survey_order.entity";
-import SurveySopItem from "./survey_sop_item.entity";
-
-@Table({
- tableName: "survey_sop_result",
- comment: "勘察 SOP 工单回执表",
- timestamps: true,
- createdAt: "created_at",
- updatedAt: "updated_at",
- indexes: [
- { name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
- { name: "idx_sop_result_order", fields: ["survey_order_id"] }
- ]
-})
-export default class SurveySopResult extends Model {
- @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
- id!: number;
-
- @ForeignKey(() => SurveyOrder)
- @Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
- surveyOrderId!: number;
-
- @ForeignKey(() => SurveySopItem)
- @Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
- sopItemId!: number;
-
- @Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
- checked!: boolean;
-
- @Column({ type: DataType.TEXT, field: "receipt_value", comment: "回执内容(照片对象key的JSON数组字符串/表单文本值/测仪截图key的JSON数组字符串)" })
- receiptValue!: string;
-
- @Column({ type: DataType.STRING(255), comment: "回执备注" })
- remark!: string;
-
- @Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
- checkedAt!: Date;
-
- @CreatedAt
- created_at!: Date;
-
- @UpdatedAt
- updated_at!: Date;
-}
diff --git a/server/src/entity/sys_job.entity.ts b/server/src/entity/sys_job.entity.ts
index baae1fa..4f7a38c 100644
--- a/server/src/entity/sys_job.entity.ts
+++ b/server/src/entity/sys_job.entity.ts
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
})
cronExpression!: string;
+ @Column({
+ type: DataType.STRING(16),
+ field: "freq_type",
+ comment: "频率类型: minute/hour/day/week/month/custom"
+ })
+ freqType!: string;
+
+ @Column({
+ type: DataType.JSONB,
+ field: "freq_config",
+ comment: "频率配置(人性化配置表单回填用)"
+ })
+ freqConfig!: Record;
+
@Column({
type: DataType.JSONB,
field: "params",
diff --git a/server/src/service/job.service.ts b/server/src/service/job.service.ts
index 718babb..850e5a5 100644
--- a/server/src/service/job.service.ts
+++ b/server/src/service/job.service.ts
@@ -8,6 +8,10 @@ import SysJobLog from "../entity/sys_job_log.entity";
import { JobSchedulerService } from "./job_scheduler.service";
import { getBuiltinJob } from "../task/job_registry";
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
+import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
+
+/** 频率预览返回的未来执行次数 */
+const PREVIEW_RUN_COUNT = 5;
@Provide()
export class JobService {
@@ -20,6 +24,29 @@ export class JobService {
@Inject()
scheduler!: JobSchedulerService;
+ /**
+ * 解析任务的频率配置:非法的频率类型或残缺的配置统一降级为「自定义」,
+ * 保证配置界面每次都能拿到可原样回填、可再次保存的配置。
+ */
+ private resolveFreq(job: SysJob): { freqType: FreqType; freqConfig: FreqConfig; description: string } {
+ const storedType = FREQ_TYPES.includes(job.freqType as FreqType) ? (job.freqType as FreqType) : "custom";
+ const schedule = buildSchedule(storedType, job.freqConfig);
+
+ if (schedule.valid) {
+ return {
+ freqType: storedType,
+ freqConfig: schedule.freqConfig ?? {},
+ description: schedule.description ?? describeCron(job.cronExpression)
+ };
+ }
+
+ return {
+ freqType: "custom",
+ freqConfig: { expression: job.cronExpression },
+ description: describeCron(job.cronExpression)
+ };
+ }
+
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise {
const page = Number(query.page) || 1;
@@ -45,9 +72,13 @@ export class JobService {
const list = rows.map(job => {
const builtin = getBuiltinJob(job.jobKey);
const registered = !!builtin;
+ const freq = this.resolveFreq(job);
return {
...job.toJSON(),
+ freqType: freq.freqType,
+ freqConfig: freq.freqConfig,
+ cronDescription: freq.description,
description: builtin ? builtin.description : null,
paramFields: builtin ? builtin.paramFields : [],
registered,
@@ -62,7 +93,7 @@ export class JobService {
};
}
- /** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
+ /** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
async update(id: number, dto: UpdateJobDTO): Promise {
const job = await this.jobModel.findByPk(id);
@@ -75,7 +106,14 @@ export class JobService {
return { code: 400, message: "任务实现不存在", data: null };
}
- if (!this.scheduler.validateCron(dto.cronExpression)) {
+ // 频率配置 → cron:非法返回具体错误,合法则同时拿到规范化配置用于落库
+ const schedule = buildSchedule(dto.freqType, dto.freqConfig);
+ if (!schedule.valid) {
+ return { code: 400, message: schedule.error ?? "频率配置不合法", data: null };
+ }
+
+ const cronExpression = schedule.cronExpression ?? "";
+ if (!this.scheduler.validateCron(cronExpression)) {
return { code: 400, message: "cron 表达式不合法", data: null };
}
@@ -122,7 +160,9 @@ export class JobService {
}
await job.update({
- cronExpression: dto.cronExpression,
+ cronExpression,
+ freqType: dto.freqType,
+ freqConfig: schedule.freqConfig ?? {},
params,
retryTimes: dto.retryTimes,
retryInterval: dto.retryInterval,
@@ -134,6 +174,37 @@ export class JobService {
return { code: 200, message: "更新定时任务成功", data: null };
}
+ /**
+ * 频率预览:由「人性化频率配置」计算 cron 表达式、中文描述与未来若干次执行时间。
+ * 配置不完整/表达式非法时仍返回 200,通过 valid/error 让前端就地提示,避免弹窗报错打断填写。
+ */
+ async previewCron(freqType: string, freqConfig?: Record): Promise {
+ const schedule = buildSchedule(freqType, freqConfig);
+
+ if (!schedule.valid) {
+ return {
+ code: 200,
+ message: "频率配置不完整",
+ data: { valid: false, error: schedule.error, cronExpression: null, description: null, nextRuns: [] }
+ };
+ }
+
+ const cronExpression = schedule.cronExpression ?? "";
+ const valid = this.scheduler.validateCron(cronExpression);
+
+ return {
+ code: 200,
+ message: "预览成功",
+ data: {
+ valid,
+ error: valid ? null : "cron 表达式不合法",
+ cronExpression,
+ description: schedule.description ?? null,
+ nextRuns: valid ? this.scheduler.nextRunTimes(cronExpression, PREVIEW_RUN_COUNT) : []
+ }
+ };
+ }
+
/** 启停任务:更新状态后即时重载调度 */
async changeStatus(id: number, status: number): Promise {
const job = await this.jobModel.findByPk(id);
diff --git a/server/src/service/job_scheduler.service.ts b/server/src/service/job_scheduler.service.ts
index fb27396..957102e 100644
--- a/server/src/service/job_scheduler.service.ts
+++ b/server/src/service/job_scheduler.service.ts
@@ -138,6 +138,21 @@ export class JobSchedulerService {
}
}
+ /** 计算未来 count 次执行时间(配置界面预览用);表达式非法返回空数组 */
+ nextRunTimes(cronExpression: string, count: number): Date[] {
+ if (!this.validateCron(cronExpression)) {
+ return [];
+ }
+
+ try {
+ return new CronTime(cronExpression, TIMEZONE)
+ .sendAt(count)
+ .map(date => date.toJSDate());
+ } catch {
+ return [];
+ }
+ }
+
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
async cleanJobLogs(keepDays: number): Promise {
const cutoff = dayjs().subtract(keepDays, "day").toDate();
diff --git a/server/src/service/survey_order.service.ts b/server/src/service/survey_order.service.ts
index 47b76da..95f1c8a 100644
--- a/server/src/service/survey_order.service.ts
+++ b/server/src/service/survey_order.service.ts
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
-import SurveySopItem from "../entity/survey_sop_item.entity";
-import SurveySopResult from "../entity/survey_sop_result.entity";
+import SurveyDetail from "../entity/survey_detail.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
@@ -18,8 +17,6 @@ import { NoticeRuleService } from "./notice_rule.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
-// 默认 SOP 模板编码
-const DEFAULT_TEMPLATE = "standard_dist";
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
@@ -38,11 +35,8 @@ export class SurveyOrderService {
@InjectRepository(Worker)
workerModel!: Repository;
- @InjectRepository(SurveySopItem)
- sopItemModel!: Repository;
-
- @InjectRepository(SurveySopResult)
- sopResultModel!: Repository;
+ @InjectRepository(SurveyDetail)
+ detailModel!: Repository;
@InjectRepository(DispatchPrice)
priceModel!: Repository;
@@ -257,13 +251,50 @@ export class SurveyOrderService {
return { baseFee, loopFee, loopCount: count, total };
}
- // 按模板批量生成回执记录(checked=false)
- private async createSopResults(orderId: number, templateCode: string): Promise {
- const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
- if (items.length === 0) return;
- await this.sopResultModel.bulkCreate(
- items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
- );
+ // 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
+ private normalizeDetails(input: any): any[] {
+ if (!Array.isArray(input)) return [];
+ return input
+ .map((row) => {
+ const photos = this.parsePhotos(row?.photos);
+ const buildingId = Number(row?.buildingId) || null;
+ const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
+ const roomId = Number(row?.roomId) || null;
+ return {
+ buildingId,
+ buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
+ floorNo: floorNo.slice(0, 16),
+ roomId,
+ roomName: row?.roomName ? String(row.roomName).slice(0, 128) : null,
+ photos: photos.length ? JSON.stringify(photos) : null,
+ remark: row?.remark ? String(row.remark).slice(0, 255) : null
+ };
+ })
+ // 建筑与楼层是明细的定位要素,缺失的行直接丢弃
+ .filter((row) => row.buildingId && row.floorNo);
+ }
+
+ // 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
+ async replaceDetails(orderId: number, details: any): Promise {
+ const rows = this.normalizeDetails(details);
+ await this.detailModel.destroy({ where: { surveyOrderId: orderId } });
+ if (rows.length === 0) return;
+ await this.detailModel.bulkCreate(rows.map((row) => ({ ...row, surveyOrderId: orderId })) as any);
+ }
+
+ // 工单明细 + 照片预签名访问 URL(管理端详情与电工端订单详情共用)
+ async getDetails(orderId: number): Promise {
+ const results = await this.detailModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
+ if (results.length === 0) return [];
+
+ const rows = results.map((r) => {
+ const json: any = r.toJSON();
+ return { ...json, photos: this.parsePhotos(json.photos) };
+ });
+
+ const keys = [...new Set(rows.flatMap((r) => r.photos))];
+ const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
+ return rows.map((row) => ({ ...row, photoUrls: row.photos.map((k: string) => urlMap[k] || "") }));
}
async list(query: any, ctx: Context): Promise {
@@ -342,13 +373,6 @@ export class SurveyOrderService {
};
}
- // SOP 模板检查项
- async listSopItems(templateCode?: string): Promise {
- const code = templateCode || DEFAULT_TEMPLATE;
- const rows = await this.sopItemModel.findAll({ where: { templateCode: code }, order: [["sort_order", "ASC"]] });
- return { code: 200, message: "获取勘察SOP检查项成功", data: rows.map((r) => r.toJSON()) };
- }
-
// 报酬试算
async quote(loopCount?: string): Promise {
const data = await this.calcFee(Number(loopCount) || 0);
@@ -362,7 +386,7 @@ export class SurveyOrderService {
const vo = this.toVO(record);
await this.decorate([vo]);
- vo.sopResults = await this.getSopResults(id);
+ vo.details = await this.getDetails(id);
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
@@ -370,84 +394,17 @@ export class SurveyOrderService {
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
- // 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
- private async getSopResults(orderId: number): Promise {
- const results = await this.sopResultModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
- if (results.length === 0) return [];
- const itemIds = [...new Set(results.map((r) => r.sopItemId).filter(Boolean))];
- const items = await this.sopItemModel.findAll({ where: { id: { [Op.in]: itemIds } } });
- const itemMap = new Map(items.map((i) => [i.id, i]));
-
- const rows = results.map((r) => {
- const json: any = r.toJSON();
- const item: any = itemMap.get(json.sopItemId);
- return {
- ...json,
- receiptValue: this.parsePhotos(json.receiptValue),
- itemName: item?.itemName || "",
- itemDesc: item?.itemDesc || "",
- receiptType: item?.receiptType || "",
- receiptRequire: item?.receiptRequire || "",
- required: item?.required ?? false
- };
- });
-
- // photo/meter 类回执是照片 key,批量换取预签名 URL 供前端直接渲染;form 类是文本值,不参与换取
- const keys = [...new Set(rows.filter((r) => r.receiptType !== "form").flatMap((r) => r.receiptValue))];
- const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
- return rows.map((row) => ({
- ...row,
- receiptUrls: row.receiptType === "form" ? [] : row.receiptValue.map((k: string) => urlMap[k] || "")
- }));
- }
-
- // 保存 SOP 工单回执(增量:只更新传入的检查项,未传入项保持原值)
- async saveSopResults(id: number, dto: any, ctx: Context): Promise {
+ // 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
+ async saveDetails(id: number, dto: any, ctx: Context): Promise {
const record = await this.surveyModel.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.SURVEYING) return { code: 400, message: "仅现场勘察中的工单可录入回执", data: null };
-
- const incoming: any[] = Array.isArray(dto?.results) ? dto.results : [];
- if (incoming.length === 0) return { code: 400, message: "回执内容不能为空", data: null };
-
- // 仅允许更新本工单已生成的回执记录,防止跨工单/跨检查项写入
- const existing = await this.sopResultModel.findAll({ where: { surveyOrderId: id } });
- const resultMap = new Map(existing.map((r) => [Number(r.sopItemId), r]));
-
- let updated = 0;
- const skipped: number[] = [];
- for (const item of incoming) {
- const sopItemId = Number(item?.sopItemId);
- const target = resultMap.get(sopItemId);
- if (!target) {
- skipped.push(sopItemId);
- continue;
- }
-
- const patch: any = {};
- if (item.checked !== undefined) {
- const checked = !!item.checked;
- patch.checked = checked;
- // 首次标记完成时记录回执时间,回退未完成则清空
- patch.checkedAt = checked ? target.checkedAt || new Date() : null;
- }
- if (item.receiptValue !== undefined) {
- const keys = this.parsePhotos(item.receiptValue);
- patch.receiptValue = keys.length ? JSON.stringify(keys) : null;
- }
- if (item.remark !== undefined) patch.remark = item.remark || null;
- if (Object.keys(patch).length === 0) continue;
-
- await target.update(patch);
- updated++;
+ if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
+ return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
- return {
- code: 200,
- message: "保存勘察回执成功",
- data: { updated, skipped, sopResults: await this.getSopResults(id) }
- };
+ await this.replaceDetails(id, dto?.details);
+ return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
async create(data: any, ctx: Context): Promise {
@@ -456,9 +413,8 @@ export class SurveyOrderService {
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
- const templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
- delete payload.templateCode;
+ delete payload.details;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
@@ -484,9 +440,13 @@ export class SurveyOrderService {
}
}
- await this.createSopResults(created.id, templateCode);
+ // 创建时若携带勘察明细则一并写入
+ if (Array.isArray(data.details) && data.details.length) {
+ await this.replaceDetails(created.id, data.details);
+ }
const vo = this.toVO(created);
await this.decorate([vo]);
+ vo.details = await this.getDetails(created.id);
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
return { code: 200, message: "创建勘察派单成功", data: vo };
}
@@ -503,13 +463,19 @@ export class SurveyOrderService {
delete data.salesmanId;
}
delete data.surveyNo;
- delete data.templateCode;
+ const details = data.details;
+ delete data.details;
const payload = this.normalizePayload(data);
await record.update(payload);
+ // 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
+ if (Array.isArray(details)) {
+ await this.replaceDetails(id, details);
+ }
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
+ vo.details = await this.getDetails(id);
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
@@ -643,8 +609,8 @@ export class SurveyOrderService {
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 this.detailModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
diff --git a/server/src/service/worker_app.service.ts b/server/src/service/worker_app.service.ts
index 2912f09..8f45a96 100644
--- a/server/src/service/worker_app.service.ts
+++ b/server/src/service/worker_app.service.ts
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity";
+import CustomerBuilding from "../entity/customer_building.entity";
+import CustomerRoom from "../entity/customer_room.entity";
import Customer from "../entity/customer.entity";
import { TokenUtil } from "../util/token";
+import { SurveyOrderService } from "./survey_order.service";
import { hashPassword, comparePassword } from "../util/crypto";
import {
WorkerRegisterDTO,
@@ -69,6 +72,15 @@ export class WorkerAppService {
@InjectRepository(SurveyOrder)
surveyModel!: Repository;
+ @InjectRepository(CustomerBuilding)
+ buildingModel!: Repository;
+
+ @InjectRepository(CustomerRoom)
+ roomModel!: Repository;
+
+ @Inject()
+ surveyOrderService!: SurveyOrderService;
+
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
@InjectDataSource()
sequelize!: Sequelize;
@@ -346,7 +358,51 @@ export class WorkerAppService {
async orderDetail(workerId: number, source: string, id: number): Promise {
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
if (!row) return { code: 404, message: "订单不存在" };
- return { code: 200, message: "获取成功", data: this.toOrderView(row.toJSON(), source) };
+ const data = this.toOrderView(row.toJSON(), source);
+ // 勘察单追加现场明细(含照片预签名 URL)与客户空间拓扑,供电工现场查看与录入
+ if (source === "survey") {
+ const [details, topology] = await Promise.all([
+ this.surveyOrderService.getDetails(Number(row.id)),
+ this.customerTopology(Number(row.customerId))
+ ]);
+ data.details = details;
+ data.topology = topology;
+ }
+ return { code: 200, message: "获取成功", data };
+ }
+
+ // 客户空间拓扑(建筑 + 房间),供勘察现场选择建筑/楼层/房间
+ private async customerTopology(customerId: number): Promise {
+ if (!customerId) return { buildings: [], rooms: [] };
+ const [buildings, rooms] = await Promise.all([
+ this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
+ this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] })
+ ]);
+ return {
+ buildings: buildings.map((b) => ({
+ id: b.id,
+ buildingName: b.buildingName,
+ floorCount: Number(b.floorCount) || 0,
+ floorDownCount: Number(b.floorDownCount) || 0
+ })),
+ rooms: rooms.map((r) => ({
+ id: r.id,
+ roomName: r.roomName,
+ buildingId: r.buildingId,
+ floorNo: r.floorNo || ""
+ }))
+ };
+ }
+
+ // 电工维护勘察明细:仅本人已接单且处于「现场勘察中(1)」的勘察单可写入(全量替换)
+ async saveSurveyDetails(workerId: number, id: number, dto: any): Promise {
+ const order = await this.surveyModel.findByPk(id);
+ if (!order) return { code: 404, message: "订单不存在" };
+ if (Number(order.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
+ if (Number(order.status) !== 1) return { code: 400, message: "仅现场勘察中的订单可维护勘察明细" };
+
+ await this.surveyOrderService.replaceDetails(id, dto?.details);
+ return { code: 200, message: "保存勘察明细成功", data: await this.surveyOrderService.getDetails(id) };
}
// ================= 接单 / 完成 =================
diff --git a/server/src/util/cron_schedule.ts b/server/src/util/cron_schedule.ts
new file mode 100644
index 0000000..2a6a081
--- /dev/null
+++ b/server/src/util/cron_schedule.ts
@@ -0,0 +1,512 @@
+/**
+ * 定时任务频率模型:把「人性化频率配置」翻译成 cron 表达式,并提供中文描述。
+ *
+ * 约定:cron_expression 始终是调度的唯一真值;freq_type / freq_config 只服务于
+ * 配置界面的回填与展示,不做任何调度判断。
+ */
+
+/** 频率类型:minute 每 N 分钟 / hour 每 N 小时 / day 每天 / week 每周 / month 每月 / custom 自定义 */
+export type FreqType = "minute" | "hour" | "day" | "week" | "month" | "custom";
+
+export const FREQ_TYPES: FreqType[] = ["minute", "hour", "day", "week", "month", "custom"];
+
+/** 频率配置(字段按 freqType 取用) */
+export interface FreqConfig {
+ /** minute/hour:间隔数量 */
+ interval?: number;
+ /** hour:每小时的第几分钟 */
+ minute?: number;
+ /** day/week/month:执行时间点,格式 HH:mm */
+ times?: string[];
+ /** week:星期,0=周日 ... 6=周六 */
+ weekdays?: number[];
+ /** month:日期 1-31 */
+ days?: number[];
+ /** custom:自定义 cron 表达式 */
+ expression?: string;
+}
+
+export interface ScheduleResult {
+ valid: boolean;
+ error?: string;
+ cronExpression?: string;
+ /** 规范化后的频率配置(去重/排序),用于原样落库保证下次编辑可回填 */
+ freqConfig?: FreqConfig;
+ description?: string;
+}
+
+/** 执行时间点上限,避免表达式过长 */
+const MAX_TIMES = 10;
+
+/** 星期文案(cron 取值 0=周日 ... 6=周六) */
+const WEEKDAY_LABELS: Record = {
+ 0: "周日",
+ 1: "周一",
+ 2: "周二",
+ 3: "周三",
+ 4: "周四",
+ 5: "周五",
+ 6: "周六"
+};
+
+function isIntegerInRange(value: unknown, min: number, max: number): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
+}
+
+function isAllField(field: string): boolean {
+ return field === "*" || field === "?";
+}
+
+function isPlainNumber(field: string): boolean {
+ return /^\d+$/.test(field);
+}
+
+/** 解析「星号斜杠 + 数字」的步长写法,非步长返回 null */
+function parseStepField(field: string): number | null {
+ const matched = /^\*\/(\d+)$/.exec(field);
+ return matched ? Number(matched[1]) : null;
+}
+
+/** 解析 HH:mm,非法返回 null */
+function parseTimeOfDay(text: unknown): { hour: number; minute: number } | null {
+ if (typeof text !== "string") {
+ return null;
+ }
+
+ const matched = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
+ if (!matched) {
+ return null;
+ }
+
+ const hour = Number(matched[1]);
+ const minute = Number(matched[2]);
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+ return null;
+ }
+
+ return { hour, minute };
+}
+
+function pad2(value: number): string {
+ return String(value).padStart(2, "0");
+}
+
+/** 时间点列表规范化:去重 + 按时间升序 */
+function normalizeTimes(times: unknown): { list: string[] } | { error: string } {
+ if (!Array.isArray(times) || times.length === 0) {
+ return { error: "请至少选择 1 个执行时间" };
+ }
+
+ if (times.length > MAX_TIMES) {
+ return { error: `执行时间最多 ${MAX_TIMES} 个` };
+ }
+
+ const minutes = new Set();
+
+ for (const item of times) {
+ const parsed = parseTimeOfDay(item);
+ if (!parsed) {
+ return { error: "执行时间格式应为 HH:mm(如 08:30)" };
+ }
+ minutes.add(parsed.hour * 60 + parsed.minute);
+ }
+
+ const list = [...minutes]
+ .sort((a, b) => a - b)
+ .map(total => `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`);
+
+ return { list };
+}
+
+/** 由时间点列表得到 cron 的分钟段与小时段(各自去重升序) */
+function timesToCronFields(list: string[]): { minutes: string; hours: string } {
+ const minutes = new Set();
+ const hours = new Set();
+
+ for (const item of list) {
+ const [hourText, minuteText] = item.split(":");
+ hours.add(Number(hourText));
+ minutes.add(Number(minuteText));
+ }
+
+ return {
+ minutes: [...minutes].sort((a, b) => a - b).join(","),
+ hours: [...hours].sort((a, b) => a - b).join(",")
+ };
+}
+
+/** 星期集合规范化:去重 + 升序(cron 段内保持标准升序) */
+function normalizeWeekdays(weekdays: unknown): { list: number[] } | { error: string } {
+ if (!Array.isArray(weekdays) || weekdays.length === 0) {
+ return { error: "请至少选择 1 个星期" };
+ }
+
+ const set = new Set();
+ for (const item of weekdays) {
+ if (!isIntegerInRange(item, 0, 6)) {
+ return { error: "星期取值非法" };
+ }
+ set.add(item);
+ }
+
+ const list = [...set].sort((a, b) => a - b);
+
+ return { list };
+}
+
+/** 日期集合规范化:去重 + 升序 */
+function normalizeDays(days: unknown): { list: number[] } | { error: string } {
+ if (!Array.isArray(days) || days.length === 0) {
+ return { error: "请至少选择 1 个日期" };
+ }
+
+ const set = new Set();
+ for (const item of days) {
+ if (!isIntegerInRange(item, 1, 31)) {
+ return { error: "日期取值需为 1-31" };
+ }
+ set.add(item);
+ }
+
+ const list = [...set].sort((a, b) => a - b);
+
+ return { list };
+}
+
+/** 星期显示文案:入参为显示序号(1=周一 ... 7=周日) */
+function weekdayLabel(displayDay: number): string {
+ return WEEKDAY_LABELS[displayDay === 7 ? 0 : displayDay];
+}
+
+/** 星期显示顺序:以周一为第 1 天、周日为第 7 天,便于区间折叠与阅读 */
+function weekdayDisplayOrder(day: number): number {
+ return day === 0 ? 7 : day;
+}
+
+/** 星期集合 → 文案:连续区间折叠为「X至Y」,其余用「、」连接 */
+function formatWeekdays(weekdays: number[]): string {
+ const ordered = [...new Set(weekdays.map(weekdayDisplayOrder))].sort((a, b) => a - b);
+ if (!ordered.length) {
+ return "";
+ }
+
+ const chunks: string[] = [];
+ let start = ordered[0];
+ let end = ordered[0];
+
+ for (let index = 1; index <= ordered.length; index++) {
+ const current: number | undefined = ordered[index];
+
+ if (current !== undefined && current === end + 1) {
+ end = current;
+ continue;
+ }
+
+ chunks.push(start === end ? weekdayLabel(start) : `${weekdayLabel(start)}至${weekdayLabel(end)}`);
+ if (current === undefined) {
+ break;
+ }
+ start = current;
+ end = current;
+ }
+
+ return chunks.join("、");
+}
+
+/** 由 freqType + freqConfig 翻译为 cron 表达式与中文描述(不含 cron 合法性校验) */
+export function buildSchedule(freqType: string, freqConfig?: FreqConfig | null): ScheduleResult {
+ const cfg: FreqConfig = freqConfig && typeof freqConfig === "object" ? freqConfig : {};
+
+ switch (freqType) {
+ case "minute": {
+ if (!isIntegerInRange(cfg.interval, 1, 59)) {
+ return { valid: false, error: "间隔需为 1-59 的整数(分钟)" };
+ }
+
+ const interval = cfg.interval;
+
+ return {
+ valid: true,
+ cronExpression: interval === 1 ? "* * * * *" : `*/${interval} * * * *`,
+ freqConfig: { interval },
+ description: interval === 1 ? "每分钟执行" : `每 ${interval} 分钟执行`
+ };
+ }
+
+ case "hour": {
+ if (!isIntegerInRange(cfg.interval, 1, 23)) {
+ return { valid: false, error: "间隔需为 1-23 的整数(小时)" };
+ }
+ if (!isIntegerInRange(cfg.minute, 0, 59)) {
+ return { valid: false, error: "分钟需为 0-59 的整数" };
+ }
+
+ const interval = cfg.interval;
+ const minute = cfg.minute;
+ const hourField = interval === 1 ? "*" : `*/${interval}`;
+ const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
+
+ return {
+ valid: true,
+ cronExpression: `${minute} ${hourField} * * *`,
+ freqConfig: { interval, minute },
+ description: interval === 1 ? `每小时${minuteText}执行` : `每 ${interval} 小时${minuteText}执行`
+ };
+ }
+
+ case "day": {
+ const normalized = normalizeTimes(cfg.times);
+ if ("error" in normalized) {
+ return { valid: false, error: normalized.error };
+ }
+
+ const fields = timesToCronFields(normalized.list);
+
+ return {
+ valid: true,
+ cronExpression: `${fields.minutes} ${fields.hours} * * *`,
+ freqConfig: { times: normalized.list },
+ description: `每天 ${normalized.list.join("、")} 执行`
+ };
+ }
+
+ case "week": {
+ const normalizedDays = normalizeWeekdays(cfg.weekdays);
+ if ("error" in normalizedDays) {
+ return { valid: false, error: normalizedDays.error };
+ }
+
+ const normalizedTimes = normalizeTimes(cfg.times);
+ if ("error" in normalizedTimes) {
+ return { valid: false, error: normalizedTimes.error };
+ }
+
+ const fields = timesToCronFields(normalizedTimes.list);
+
+ return {
+ valid: true,
+ cronExpression: `${fields.minutes} ${fields.hours} * * ${normalizedDays.list.join(",")}`,
+ freqConfig: { weekdays: normalizedDays.list, times: normalizedTimes.list },
+ description: `每周(${formatWeekdays(normalizedDays.list)})${normalizedTimes.list.join("、")} 执行`
+ };
+ }
+
+ case "month": {
+ const normalizedDays = normalizeDays(cfg.days);
+ if ("error" in normalizedDays) {
+ return { valid: false, error: normalizedDays.error };
+ }
+
+ const normalizedTimes = normalizeTimes(cfg.times);
+ if ("error" in normalizedTimes) {
+ return { valid: false, error: normalizedTimes.error };
+ }
+
+ const fields = timesToCronFields(normalizedTimes.list);
+
+ return {
+ valid: true,
+ cronExpression: `${fields.minutes} ${fields.hours} ${normalizedDays.list.join(",")} * *`,
+ freqConfig: { days: normalizedDays.list, times: normalizedTimes.list },
+ description: `每月 ${normalizedDays.list.join("、")} 日 ${normalizedTimes.list.join("、")} 执行`
+ };
+ }
+
+ case "custom": {
+ const expression = typeof cfg.expression === "string" ? cfg.expression.trim() : "";
+
+ if (!expression) {
+ return { valid: false, error: "请输入 cron 表达式" };
+ }
+ if (expression.length > 64) {
+ return { valid: false, error: "cron 表达式长度不能超过 64" };
+ }
+
+ // 表达式合法性由调度器(cron 包)统一校验,这里只做描述
+ return {
+ valid: true,
+ cronExpression: expression,
+ freqConfig: { expression },
+ description: describeCron(expression)
+ };
+ }
+
+ default:
+ return { valid: false, error: "频率类型非法" };
+ }
+}
+
+/** 解析逗号分隔的分钟/小时段;含区间、步长等复杂写法时返回 null */
+function parseSimpleNumberField(field: string, min: number, max: number): number[] | null {
+ if (isAllField(field)) {
+ return null;
+ }
+
+ const parts = field.split(",");
+ const values: number[] = [];
+
+ for (const part of parts) {
+ if (!isPlainNumber(part)) {
+ return null;
+ }
+
+ const value = Number(part);
+ if (value < min || value > max) {
+ return null;
+ }
+
+ values.push(value);
+ }
+
+ return values.length ? [...new Set(values)].sort((a, b) => a - b) : null;
+}
+
+/** 由分钟段与小时段还原成 HH:mm 列表(两者均为简单数字列表时) */
+function parseTimeList(minuteField: string, hourField: string): string[] | null {
+ const minutes = parseSimpleNumberField(minuteField, 0, 59);
+ const hours = parseSimpleNumberField(hourField, 0, 23);
+
+ if (!minutes || !hours) {
+ return null;
+ }
+
+ const list: string[] = [];
+ for (const hour of hours) {
+ for (const minute of minutes) {
+ list.push(`${pad2(hour)}:${pad2(minute)}`);
+ }
+ }
+
+ return list;
+}
+
+/** 解析星期段(cron 取值,0=周日):支持数字、逗号列表、a-b 区间;无法识别返回 null */
+function parseWeekdayField(field: string): number[] | null {
+ const list: number[] = [];
+
+ for (const part of field.split(",")) {
+ const rangeMatched = /^(\d)-(\d)$/.exec(part);
+
+ if (rangeMatched) {
+ const start = Number(rangeMatched[1]);
+ const end = Number(rangeMatched[2]);
+ // cron 中 7 等价于周日
+ if (start > end || start < 0 || end > 7) {
+ return null;
+ }
+
+ for (let day = start; day <= end; day++) {
+ list.push(day === 7 ? 0 : day);
+ }
+ continue;
+ }
+
+ if (!isPlainNumber(part)) {
+ return null;
+ }
+
+ const day = Number(part);
+ if (day < 0 || day > 7) {
+ return null;
+ }
+
+ list.push(day === 7 ? 0 : day);
+ }
+
+ return [...new Set(list)].sort((a, b) => a - b);
+}
+
+/**
+ * cron 表达式 → 中文描述。
+ * 只覆盖本系统会生成/常见的写法(通配、简单数字列表、步长、星期区间),
+ * 无法有把握描述时原样返回表达式,避免给出误导性文案。
+ */
+export function describeCron(expression: string): string {
+ if (!expression || typeof expression !== "string") {
+ return expression;
+ }
+
+ const fields = expression.trim().split(/\s+/);
+ let minuteField: string;
+ let hourField: string;
+ let dayOfMonthField: string;
+ let monthField: string;
+ let dayOfWeekField: string;
+
+ if (fields.length === 6) {
+ const [secondField, ...rest] = fields;
+ // 秒不为 0 的表达式不描述,原样返回
+ if (secondField !== "0") {
+ return expression;
+ }
+ [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = rest;
+ } else if (fields.length === 5) {
+ [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;
+ } else {
+ return expression;
+ }
+
+ // 月份限定不描述
+ if (!isAllField(monthField)) {
+ return expression;
+ }
+
+ const dayOfMonthFixed = !isAllField(dayOfMonthField);
+ const dayOfWeekFixed = !isAllField(dayOfWeekField);
+ // 日与星期同时限定在 cron 中是「或」语义,为避免歧义不做描述
+ if (dayOfMonthFixed && dayOfWeekFixed) {
+ return expression;
+ }
+
+ if (dayOfMonthFixed) {
+ const days = parseSimpleNumberField(dayOfMonthField, 1, 31);
+ const times = parseTimeList(minuteField, hourField);
+ if (!days || !times) {
+ return expression;
+ }
+
+ return `每月 ${days.join("、")} 日 ${times.join("、")} 执行`;
+ }
+
+ if (dayOfWeekFixed) {
+ const weekdays = parseWeekdayField(dayOfWeekField);
+ const times = parseTimeList(minuteField, hourField);
+ if (!weekdays || !times) {
+ return expression;
+ }
+
+ if (weekdays.length === 7) {
+ return `每天 ${times.join("、")} 执行`;
+ }
+
+ return `每周(${formatWeekdays(weekdays)})${times.join("、")} 执行`;
+ }
+
+ if (isAllField(minuteField) && isAllField(hourField)) {
+ return "每分钟执行";
+ }
+
+ const minuteStep = parseStepField(minuteField);
+ if (minuteStep !== null && isAllField(hourField)) {
+ return minuteStep === 1 ? "每分钟执行" : `每 ${minuteStep} 分钟执行`;
+ }
+
+ if (isPlainNumber(minuteField) && isAllField(hourField)) {
+ const minute = Number(minuteField);
+ return minute === 0 ? "每小时整点执行" : `每小时第 ${minute} 分执行`;
+ }
+
+ const hourStep = parseStepField(hourField);
+ if (hourStep !== null && isPlainNumber(minuteField)) {
+ const minute = Number(minuteField);
+ const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
+ return hourStep === 1 ? `每小时${minuteText}执行` : `每 ${hourStep} 小时${minuteText}执行`;
+ }
+
+ const times = parseTimeList(minuteField, hourField);
+ if (!times) {
+ return expression;
+ }
+
+ return `每天 ${times.join("、")} 执行`;
+}
\ No newline at end of file