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.remark }}
-
- -
-
- {{ row.receiptValue.join('、') }} -
-
备注:{{ row.remark }}
@@ -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 @@
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 }} 未注册 + 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