feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
1 parent
f72644c24f
commit
22d27a940d
29 files changed
+1908
-576
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
# 开发环境变量
|
# 开发环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 生产环境变量
|
# 生产环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Trae Project</title>
|
<title>鑫潼智电管理系统</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (import.meta.hot?.on) {
|
if (import.meta.hot?.on) {
|
||||||
import.meta.hot.on('vite:error', (error) => {
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
|||||||
+37
-1
@@ -9,11 +9,33 @@ export interface JobParamField {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率类型:每N分钟 / 每N小时 / 每天 / 每周 / 每月 / 自定义 cron */
|
||||||
|
export type 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 JobItem {
|
export interface JobItem {
|
||||||
id: number;
|
id: number;
|
||||||
jobKey: string;
|
jobKey: string;
|
||||||
jobName: string;
|
jobName: string;
|
||||||
cronExpression: string;
|
cronExpression: string;
|
||||||
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
|
cronDescription?: string | null;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
@@ -71,13 +93,23 @@ export interface JobLogQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateJobPayload {
|
export interface UpdateJobPayload {
|
||||||
cronExpression: string;
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览结果:由后端由频率配置推导 cron、中文描述与未来执行时间 */
|
||||||
|
export interface CronPreviewResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
cronExpression?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
nextRuns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobListResult {
|
export interface JobListResult {
|
||||||
list: JobItem[];
|
list: JobItem[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -99,6 +131,10 @@ export const jobApi = {
|
|||||||
updateJob: (id: number, data: UpdateJobPayload) =>
|
updateJob: (id: number, data: UpdateJobPayload) =>
|
||||||
request.put<JobItem>(`/jobs/${id}`, data),
|
request.put<JobItem>(`/jobs/${id}`, data),
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来 5 次执行时间 */
|
||||||
|
previewCron: (data: { freqType: FreqType; freqConfig: FreqConfig }) =>
|
||||||
|
request.post<CronPreviewResult>('/jobs/cron/preview', data),
|
||||||
|
|
||||||
changeJobStatus: (id: number, status: 0 | 1) =>
|
changeJobStatus: (id: number, status: 0 | 1) =>
|
||||||
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ export const SURVEY_PRIORITY_OPTIONS = [
|
|||||||
{ value: 3, label: '常规 · P3' }
|
{ value: 3, label: '常规 · P3' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// SOP 规范模板编码与名称
|
|
||||||
export const SOP_TEMPLATE_CODE = 'standard_dist';
|
|
||||||
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
|
|
||||||
|
|
||||||
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
||||||
export const SURVEY_BASE_FEE = 350;
|
export const SURVEY_BASE_FEE = 350;
|
||||||
export const SURVEY_LOOP_FEE = 5;
|
export const SURVEY_LOOP_FEE = 5;
|
||||||
@@ -61,35 +57,22 @@ export interface SurveyOrderWorker {
|
|||||||
completedCount?: number;
|
completedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveySopItem {
|
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
|
||||||
id: number;
|
export interface SurveyOrderDetail {
|
||||||
templateCode: string;
|
id?: number;
|
||||||
sortOrder: number;
|
surveyOrderId?: number;
|
||||||
itemName: string;
|
buildingId?: number | null;
|
||||||
itemDesc?: string;
|
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
||||||
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
|
buildingName?: string | null;
|
||||||
receiptType: 'photo' | 'form' | 'meter' | string;
|
// 楼层:正数为地上、负数为地下,如 '1' / '-1'
|
||||||
receiptRequire?: string;
|
floorNo?: string | null;
|
||||||
required: boolean;
|
roomId?: number | null;
|
||||||
}
|
roomName?: string | null;
|
||||||
|
// 现场照片对象 key
|
||||||
export interface SurveyOrderSopResult {
|
photos?: string[];
|
||||||
id: number;
|
// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)
|
||||||
surveyOrderId: number;
|
photoUrls?: string[];
|
||||||
sopItemId: number;
|
remark?: string | null;
|
||||||
checked: boolean;
|
|
||||||
// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组
|
|
||||||
receiptValue?: string[];
|
|
||||||
// 详情/回执接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)
|
|
||||||
receiptUrls?: string[];
|
|
||||||
remark?: string;
|
|
||||||
checkedAt?: string;
|
|
||||||
// 由后端 join 的检查项信息
|
|
||||||
sopItem?: SurveySopItem;
|
|
||||||
itemName?: string;
|
|
||||||
receiptType?: string;
|
|
||||||
receiptRequire?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderInfo {
|
export interface SurveyOrderInfo {
|
||||||
@@ -141,8 +124,8 @@ export interface SurveyOrderInfo {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
// 详情附带:SOP 回执
|
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
|
||||||
sopResults?: SurveyOrderSopResult[];
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderQuery {
|
export interface SurveyOrderQuery {
|
||||||
@@ -209,7 +192,8 @@ export interface SurveyOrderCreateParams {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
isDraft?: number;
|
isDraft?: number;
|
||||||
templateCode?: string;
|
// 勘察明细(创建/编辑时全量提交,新增/编辑/删除统一走此数组)
|
||||||
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
||||||
@@ -247,10 +231,6 @@ export const surveyOrderApi = {
|
|||||||
// KPI 统计卡
|
// KPI 统计卡
|
||||||
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
||||||
|
|
||||||
// SOP 规范模板检查项
|
|
||||||
getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) =>
|
|
||||||
request.get<SurveySopItem[]>('/survey-orders/sop-items', { params: { templateCode } }),
|
|
||||||
|
|
||||||
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
||||||
getQuote: (loopCount: number) =>
|
getQuote: (loopCount: number) =>
|
||||||
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
||||||
@@ -264,6 +244,10 @@ export const surveyOrderApi = {
|
|||||||
update: (id: number, data: SurveyOrderUpdateParams) =>
|
update: (id: number, data: SurveyOrderUpdateParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
||||||
|
|
||||||
|
// 勘察明细:全量替换(新增/编辑/删除统一提交完整数组)
|
||||||
|
saveDetails: (id: number, details: SurveyOrderDetail[]) =>
|
||||||
|
request.put<SurveyOrderDetail[]>(`/survey-orders/${id}/details`, { details }),
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
assign: (id: number, data: SurveyOrderAssignParams) =>
|
assign: (id: number, data: SurveyOrderAssignParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Logo区域 -->
|
<!-- Logo区域 -->
|
||||||
<div class="logo" @click="$router.push('/dashboard')">
|
<div class="logo" @click="$router.push('/dashboard')">
|
||||||
<ThunderboltOutlined class="logo-img" />
|
<ThunderboltOutlined class="logo-img" />
|
||||||
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
|
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 菜单 -->
|
<!-- 菜单 -->
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
document.title = to.meta.title ? `${to.meta.title} - 智能物联网设备管理系统` : "智能物联网设备管理系统";
|
document.title = to.meta.title ? `${to.meta.title} - 鑫潼智电管理系统` : "鑫潼智电管理系统";
|
||||||
|
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
@@ -229,34 +229,135 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="bar bar-secondary"></span>
|
<span class="bar bar-secondary"></span>
|
||||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||||
<h2>第 2 步:勘察技术要点与回执标准</h2>
|
<h2>第 2 步:勘察明细</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isReadonly" class="tmpl-box">
|
<div v-if="!isReadonly" class="head-actions">
|
||||||
<span class="head-hint">SOP规范模板:</span>
|
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
|
||||||
<button type="button" class="tmpl-btn" @click="applySopTemplate">
|
<a-button size="small" type="primary" ghost @click="addDetailRow">
|
||||||
<PlusOutlined />
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
|
添加明细
|
||||||
</button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
|
<div class="detail-body">
|
||||||
<div v-for="item in sopItems" :key="item.id" class="sop-card">
|
<!-- 编辑态:明细编辑行 -->
|
||||||
<div class="sop-top">
|
<template v-if="!isReadonly">
|
||||||
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">
|
||||||
<span v-if="item.required" class="sop-required">必填</span>
|
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
|
||||||
</div>
|
</div>
|
||||||
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
|
<div v-else class="detail-rows">
|
||||||
<div class="sop-foot">
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
|
||||||
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
|
<div class="detail-row-head">
|
||||||
<span>{{ item.receiptRequire || '—' }}</span>
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">建筑 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.buildingId"
|
||||||
|
placeholder="选择建筑"
|
||||||
|
:loading="topologyLoading"
|
||||||
|
:options="buildingSelectOptions"
|
||||||
|
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowBuildingChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">楼层 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.floorNo"
|
||||||
|
placeholder="选择楼层"
|
||||||
|
:disabled="!row.buildingId"
|
||||||
|
:options="floorOptionsFor(row.buildingId)"
|
||||||
|
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowFloorChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">房间 <span class="opt">选填</span></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.roomId"
|
||||||
|
placeholder="可不选"
|
||||||
|
allow-clear
|
||||||
|
:disabled="!row.floorNo"
|
||||||
|
:options="roomOptionsFor(row)"
|
||||||
|
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowRoomChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">现场照片</label>
|
||||||
|
<div class="detail-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
|
||||||
|
<a-image :src="p.url" :width="76" :height="57" />
|
||||||
|
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
|
||||||
|
</div>
|
||||||
|
</a-image-preview-group>
|
||||||
|
<a-upload
|
||||||
|
:show-upload-list="false"
|
||||||
|
:custom-request="(o: any) => handlePhotoUpload(o, row)"
|
||||||
|
:before-upload="beforePhotoUpload"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
|
||||||
|
<PlusOutlined />
|
||||||
|
<span>上传照片</span>
|
||||||
|
</button>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">备注</label>
|
||||||
|
<input
|
||||||
|
v-model="row.remark"
|
||||||
|
class="ipt"
|
||||||
|
type="text"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="该条明细的现场说明,如设备位置、隐患点等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
|
<!-- 只读态:明细展示 -->
|
||||||
<PlusOutlined class="sop-add-icon" />
|
<template v-else>
|
||||||
<span>+ 自定义勘察检查项</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
|
||||||
</div>
|
<div v-else class="detail-rows">
|
||||||
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
|
||||||
|
<div class="detail-ro-head">
|
||||||
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<span class="detail-ro-scope">
|
||||||
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(p, pi) in row.photos"
|
||||||
|
:key="pi"
|
||||||
|
:src="p.url"
|
||||||
|
:width="110"
|
||||||
|
:height="82"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -362,10 +463,6 @@
|
|||||||
<span class="ro-k">测绘进度</span>
|
<span class="ro-k">测绘进度</span>
|
||||||
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ro-fact">
|
|
||||||
<span class="ro-k">SOP 回执</span>
|
|
||||||
<span class="ro-v mono">{{ roSopChecked }} / {{ roSopResults.length }} 项</span>
|
|
||||||
</div>
|
|
||||||
<div class="ro-fact">
|
<div class="ro-fact">
|
||||||
<span class="ro-k">现场照片</span>
|
<span class="ro-k">现场照片</span>
|
||||||
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
||||||
@@ -387,25 +484,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOP 回执明细 -->
|
|
||||||
<div class="ro-block">
|
|
||||||
<div class="ro-block-title">SOP 回执明细</div>
|
|
||||||
<div v-if="roSopResults.length === 0" class="ro-empty">暂无 SOP 回执记录</div>
|
|
||||||
<div v-else class="ro-list">
|
|
||||||
<div v-for="(r, idx) in roSopResults" :key="r.id || idx" class="ro-sop-row">
|
|
||||||
<span class="ro-sop-check" :class="{ done: r.checked }">
|
|
||||||
<CheckOutlined v-if="r.checked" />
|
|
||||||
</span>
|
|
||||||
<span class="ro-sop-name">{{ r.itemName || r.sopItem?.itemName || `检查项 #${r.sopItemId}` }}</span>
|
|
||||||
<span class="ro-sop-tag">{{ receiptTypeLabel(r.receiptType || r.sopItem?.receiptType) }}</span>
|
|
||||||
<div v-if="r.receiptUrls && r.receiptUrls.length" class="ro-sop-thumbs">
|
|
||||||
<a-image v-for="(url, i) in r.receiptUrls" :key="i" :src="url" :width="56" :height="42" class="ro-sop-thumb" />
|
|
||||||
</div>
|
|
||||||
<span v-else class="ro-sop-value mono">{{ r.receiptValue?.length ? r.receiptValue.join('、') : r.remark || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 现场取证照片 -->
|
<!-- 现场取证照片 -->
|
||||||
<div class="ro-block">
|
<div class="ro-block">
|
||||||
<div class="ro-block-title">现场取证照片</div>
|
<div class="ro-block-title">现场取证照片</div>
|
||||||
@@ -500,26 +578,27 @@ import {
|
|||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
StarFilled,
|
StarFilled,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
surveyOrderApi,
|
surveyOrderApi,
|
||||||
SOP_TEMPLATE_CODE,
|
|
||||||
SOP_TEMPLATE_LABEL,
|
|
||||||
SURVEY_STATUS,
|
SURVEY_STATUS,
|
||||||
SURVEY_PRIORITY,
|
SURVEY_PRIORITY,
|
||||||
SURVEY_PRIORITY_OPTIONS,
|
SURVEY_PRIORITY_OPTIONS,
|
||||||
SURVEY_BASE_FEE,
|
SURVEY_BASE_FEE,
|
||||||
SURVEY_LOOP_FEE,
|
SURVEY_LOOP_FEE,
|
||||||
type SurveySopItem,
|
|
||||||
type SurveyQuote,
|
type SurveyQuote,
|
||||||
type SurveyOrderCreateParams,
|
type SurveyOrderCreateParams,
|
||||||
type SurveyOrderInfo,
|
type SurveyOrderInfo,
|
||||||
|
type SurveyOrderDetail,
|
||||||
} from '@/api/surveyOrder';
|
} from '@/api/surveyOrder';
|
||||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
|
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
|
||||||
|
import { uploadApi } from '@/api/upload';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -527,16 +606,10 @@ const route = useRoute();
|
|||||||
// ==================== 步骤元数据 ====================
|
// ==================== 步骤元数据 ====================
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||||
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
|
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
||||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECEIPT_TYPE_LABEL: Record<string, string> = {
|
|
||||||
photo: '现场照片',
|
|
||||||
form: '清单格式',
|
|
||||||
meter: '手持测仪',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SLA_OPTIONS = [
|
const SLA_OPTIONS = [
|
||||||
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
||||||
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
||||||
@@ -571,8 +644,6 @@ const STATUS_META: Record<number, { label: string; color: string; bg: string }>
|
|||||||
|
|
||||||
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
||||||
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
||||||
const roSopResults = computed(() => detailRecord.value?.sopResults || []);
|
|
||||||
const roSopChecked = computed(() => roSopResults.value.filter((r) => r.checked).length);
|
|
||||||
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
||||||
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
||||||
const roProgressPercent = computed(() => {
|
const roProgressPercent = computed(() => {
|
||||||
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||||
|
|
||||||
// ==================== SOP 检查项 ====================
|
// ==================== 客户空间拓扑 ====================
|
||||||
const sopItems = ref<SurveySopItem[]>([]);
|
const topology = ref<CustomerTopologyResult | null>(null);
|
||||||
const sopLoading = ref(false);
|
const topologyLoading = ref(false);
|
||||||
|
const buildingSelectOptions = computed(() =>
|
||||||
|
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
|
||||||
|
);
|
||||||
|
|
||||||
function receiptTypeLabel(type?: string) {
|
function buildingById(id?: number): TopologyBuilding | undefined {
|
||||||
if (!type) return '—';
|
if (!id) return undefined;
|
||||||
return RECEIPT_TYPE_LABEL[type] || type;
|
return (topology.value?.buildings || []).find((b) => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
|
||||||
|
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
|
||||||
|
if (!b) return [];
|
||||||
|
const up = b.floorCount ?? 0;
|
||||||
|
const down = b.floorDownCount ?? 0;
|
||||||
|
const opts: { label: string; value: string }[] = [];
|
||||||
|
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
|
||||||
|
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorOptionsFor(buildingId?: number) {
|
||||||
|
return buildFloorOptions(buildingById(buildingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 roomOptionsFor(row: DetailRow) {
|
||||||
|
const b = buildingById(row.buildingId);
|
||||||
|
if (!b || !row.floorNo) return [];
|
||||||
|
return b.floors
|
||||||
|
.filter((f) => (f.floorNo || '') === row.floorNo)
|
||||||
|
.flatMap((f) => f.rooms)
|
||||||
|
.map((r) => ({ label: r.roomName, value: r.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopology() {
|
||||||
|
if (!formState.customerId) {
|
||||||
|
topology.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
topologyLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerCircuitApi.getTopology(formState.customerId);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
topology.value = res.data;
|
||||||
|
} else {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
} finally {
|
||||||
|
topologyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 勘察明细 ====================
|
||||||
|
interface DetailPhoto {
|
||||||
|
// 对象存储 key(提交时使用)
|
||||||
|
key: string;
|
||||||
|
// 预签名访问 URL(预览使用)
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
uid: number;
|
||||||
|
buildingId?: number;
|
||||||
|
buildingName?: string;
|
||||||
|
floorNo?: string;
|
||||||
|
roomId?: number;
|
||||||
|
roomName?: string;
|
||||||
|
photos: DetailPhoto[];
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DETAIL_PHOTOS = 9;
|
||||||
|
let detailUidSeed = 0;
|
||||||
|
const nextDetailUid = () => ++detailUidSeed;
|
||||||
|
|
||||||
|
const detailRows = ref<DetailRow[]>([]);
|
||||||
|
|
||||||
|
function addDetailRow() {
|
||||||
|
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDetailRow(index: number) {
|
||||||
|
detailRows.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowBuildingChange(row: DetailRow) {
|
||||||
|
row.buildingName = buildingById(row.buildingId)?.buildingName;
|
||||||
|
row.floorNo = undefined;
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowFloorChange(row: DetailRow) {
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowRoomChange(row: DetailRow) {
|
||||||
|
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
|
||||||
|
row.roomName = room?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforePhotoUpload(file: File) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('仅支持上传图片文件');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.error('图片大小不能超过 10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoUpload(options: any, row: DetailRow) {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
try {
|
||||||
|
const res: any = await uploadApi.uploadImage(file as File);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
row.photos.push({ key: res.data.key, url: res.data.url });
|
||||||
|
onSuccess?.(res.data);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.message || '上传失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '照片上传失败');
|
||||||
|
onError?.(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(row: DetailRow, index: number) {
|
||||||
|
row.photos.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
|
||||||
|
function buildDetails(): SurveyOrderDetail[] {
|
||||||
|
return detailRows.value
|
||||||
|
.filter((row) => !!row.buildingId && !!row.floorNo)
|
||||||
|
.map((row) => ({
|
||||||
|
buildingId: row.buildingId,
|
||||||
|
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
|
||||||
|
floorNo: row.floorNo,
|
||||||
|
roomId: row.roomId ?? undefined,
|
||||||
|
roomName: row.roomName || undefined,
|
||||||
|
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
|
||||||
|
remark: row.remark.trim() || undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 报酬与电工 ====================
|
// ==================== 报酬与电工 ====================
|
||||||
@@ -690,6 +913,9 @@ function handleCustomerChange() {
|
|||||||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
||||||
|
detailRows.value = [];
|
||||||
|
loadTopology();
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
|
|||||||
formState.workerId = w.id;
|
formState.workerId = w.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySopTemplate() {
|
|
||||||
if (sopItems.value.length === 0) {
|
|
||||||
loadSopItems();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message.success(`已应用${SOP_TEMPLATE_LABEL} ${sopItems.value.length} 项检查标准`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddCustomItem() {
|
|
||||||
message.info('自定义勘察检查项功能暂未开放');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 数据加载 ====================
|
// ==================== 数据加载 ====================
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
try {
|
try {
|
||||||
@@ -723,20 +937,6 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSopItems() {
|
|
||||||
sopLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await surveyOrderApi.getSopItems(SOP_TEMPLATE_CODE);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
sopItems.value = res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('SOP 检查项加载失败');
|
|
||||||
} finally {
|
|
||||||
sopLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadQuote() {
|
async function loadQuote() {
|
||||||
const count = Number(formState.loopCount) || 0;
|
const count = Number(formState.loopCount) || 0;
|
||||||
try {
|
try {
|
||||||
@@ -802,6 +1002,17 @@ async function loadDetail() {
|
|||||||
d.planEnd ? dayjs(d.planEnd) : null,
|
d.planEnd ? dayjs(d.planEnd) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
|
||||||
|
detailRows.value = (d.details || []).map((dt) => ({
|
||||||
|
uid: nextDetailUid(),
|
||||||
|
buildingId: dt.buildingId ?? undefined,
|
||||||
|
buildingName: dt.buildingName ?? undefined,
|
||||||
|
floorNo: dt.floorNo ?? undefined,
|
||||||
|
roomId: dt.roomId ?? undefined,
|
||||||
|
roomName: dt.roomName ?? undefined,
|
||||||
|
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
|
||||||
|
remark: dt.remark || '',
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message || '获取勘察派单详情失败');
|
message.error(res.message || '获取勘察派单详情失败');
|
||||||
}
|
}
|
||||||
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
|||||||
remark: formState.remark.trim() || undefined,
|
remark: formState.remark.trim() || undefined,
|
||||||
source: 'ledger',
|
source: 'ledger',
|
||||||
isDraft,
|
isDraft,
|
||||||
templateCode: SOP_TEMPLATE_CODE,
|
details: buildDetails(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,8 +1129,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadCustomers();
|
await loadCustomers();
|
||||||
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
|
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
|
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1338,107 +1550,178 @@ onMounted(async () => {
|
|||||||
background: #0054cd;
|
background: #0054cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 第 2 步:SOP ---------- */
|
/* ---------- 第 2 步:勘察明细 ---------- */
|
||||||
.tmpl-box {
|
.head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tmpl-btn {
|
.detail-body {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
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);
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 18px 20px 20px;
|
padding: 18px 20px 20px;
|
||||||
}
|
}
|
||||||
.sop-grid.is-empty {
|
.detail-empty {
|
||||||
grid-template-columns: 1fr;
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
background: #eef4ff;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.sop-card {
|
.detail-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid rgba(219, 228, 238, 0.5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eef4ff;
|
|
||||||
}
|
}
|
||||||
.sop-top {
|
.detail-row {
|
||||||
display: flex;
|
border: 1px solid #dbe4ee;
|
||||||
align-items: flex-start;
|
border-radius: 10px;
|
||||||
justify-content: space-between;
|
background: #f8fafc;
|
||||||
gap: 8px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sop-name {
|
.detail-row-head {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 8px;
|
gap: 8px;
|
||||||
border-top: 1px solid rgba(219, 228, 238, 0.5);
|
padding: 8px 14px;
|
||||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
background: #eef4ff;
|
||||||
|
border-bottom: 1px solid #e2eaf5;
|
||||||
|
}
|
||||||
|
.detail-row-idx {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add {
|
.detail-grid {
|
||||||
min-height: 96px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
width: 76px;
|
||||||
|
height: 57px;
|
||||||
border: 1px dashed #c2c6d8;
|
border: 1px dashed #c2c6d8;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
color: #5b6b7e;
|
color: #5b6b7e;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.sop-add:hover {
|
.photo-add:hover:not(:disabled) {
|
||||||
border-color: rgba(0, 84, 205, 0.5);
|
border-color: rgba(0, 84, 205, 0.5);
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add-icon {
|
.photo-add:disabled {
|
||||||
font-size: 22px;
|
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 步:电工与报酬 ---------- */
|
/* ---------- 第 3 步:电工与报酬 ---------- */
|
||||||
@@ -1719,61 +2002,6 @@ onMounted(async () => {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 10px;
|
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 {
|
.ro-photo-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1868,7 +2096,7 @@ onMounted(async () => {
|
|||||||
.field-grid {
|
.field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.sop-grid {
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.cand-grid {
|
.cand-grid {
|
||||||
|
|||||||
@@ -514,29 +514,31 @@
|
|||||||
<div v-else class="detail-text">—</div>
|
<div v-else class="detail-text">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detailRecord.sopResults?.length" class="detail-block">
|
<div v-if="detailRecord.details?.length" class="detail-block">
|
||||||
<div class="detail-label">
|
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||||
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
|
<div class="detail-list">
|
||||||
</div>
|
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
|
||||||
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
|
<div class="detail-row-head">
|
||||||
<div class="sop-item-head">
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
|
<span class="detail-row-scope">
|
||||||
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photoUrls?.length" class="photo-list">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(url, pi) in row.photoUrls"
|
||||||
|
:key="pi"
|
||||||
|
:src="url"
|
||||||
|
:width="110"
|
||||||
|
:height="80"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-row-remark">备注:{{ row.remark }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.receiptUrls?.length" class="photo-list">
|
|
||||||
<a-image
|
|
||||||
v-for="(url, idx) in row.receiptUrls"
|
|
||||||
:key="idx"
|
|
||||||
:src="url"
|
|
||||||
:width="110"
|
|
||||||
:height="80"
|
|
||||||
style="object-fit: cover; border-radius: 8px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="row.receiptValue?.length" class="detail-text">
|
|
||||||
{{ row.receiptValue.join('、') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="row.remark" class="sop-item-remark">备注:{{ row.remark }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
|
|||||||
// ==================== 详情 / 编辑 ====================
|
// ==================== 详情 / 编辑 ====================
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||||||
const detailSopDone = computed(
|
|
||||||
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openDetail(record: SurveyOrderInfo) {
|
async function openDetail(record: SurveyOrderInfo) {
|
||||||
detailRecord.value = record;
|
detailRecord.value = record;
|
||||||
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
|
|||||||
return `${s} ~ ${e}`;
|
return `${s} ~ ${e}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sopChipStyle = (checked?: boolean) =>
|
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
|
||||||
chipStyle(
|
function floorLabel(floorNo?: string | null) {
|
||||||
checked
|
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||||
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
|
const n = Number(floorNo);
|
||||||
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
|
if (Number.isNaN(n)) return String(floorNo);
|
||||||
);
|
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
|
}
|
||||||
|
|
||||||
function money(value?: number | string) {
|
function money(value?: number | string) {
|
||||||
if (value == null || value === '') return '—';
|
if (value == null || value === '') return '—';
|
||||||
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item {
|
.detail-list {
|
||||||
padding: 10px 0;
|
display: flex;
|
||||||
border-bottom: 1px dashed #eef1f6;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item:last-child {
|
.detail-row {
|
||||||
border-bottom: none;
|
display: flex;
|
||||||
padding-bottom: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 6px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-name {
|
.detail-row-idx {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-scope {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0d1c2c;
|
color: #0d1c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-remark {
|
.detail-row-remark {
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
color: #8a97a8;
|
color: #8a97a8;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AntD 覆盖 */
|
/* AntD 覆盖 */
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="title">智能电网设备管理系统</h1>
|
<h1 class="title">鑫潼智电管理系统</h1>
|
||||||
<p class="subtitle">Smart Grid IoT Management System</p>
|
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
<p>© 2026 智能电网设备管理系统 All Rights Reserved</p>
|
<p>© 2026 鑫潼智电管理系统 All Rights Reserved</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
<span>{{ record.jobName }}</span>
|
<span>{{ record.jobName }}</span>
|
||||||
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'frequency'">
|
||||||
|
<div class="freq-cell">
|
||||||
|
<span>{{ record.cronDescription || record.cronExpression }}</span>
|
||||||
|
<code class="freq-cron">{{ record.cronExpression }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'params'">
|
<template v-else-if="column.key === 'params'">
|
||||||
<a-tooltip :title="formatParams(record.params, true)">
|
<a-tooltip :title="formatParams(record.params, true)">
|
||||||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||||||
@@ -111,7 +117,7 @@
|
|||||||
title="编辑任务"
|
title="编辑任务"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
width="640px"
|
width="720px"
|
||||||
@ok="handleSave"
|
@ok="handleSave"
|
||||||
@cancel="handleCancelEdit"
|
@cancel="handleCancelEdit"
|
||||||
>
|
>
|
||||||
@@ -125,9 +131,110 @@
|
|||||||
<a-form-item label="任务名称">
|
<a-form-item label="任务名称">
|
||||||
<span>{{ currentJob?.jobName || '-' }}</span>
|
<span>{{ currentJob?.jobName || '-' }}</span>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="cron 表达式" name="cronExpression">
|
|
||||||
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
|
<a-divider orientation="left" class="form-divider">执行频率</a-divider>
|
||||||
|
|
||||||
|
<a-form-item label="频率类型">
|
||||||
|
<a-select v-model:value="formData.freqType" :options="freqTypeOptions" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'minute'" label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分钟"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-if="formData.freqType === 'hour'">
|
||||||
|
<a-form-item label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="23"
|
||||||
|
addon-after="小时"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="第几分钟">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.minute"
|
||||||
|
:min="0"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'week'" label="星期">
|
||||||
|
<a-checkbox-group v-model:value="formData.weekdays" :options="weekdayOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'month'" label="日期">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.days"
|
||||||
|
mode="multiple"
|
||||||
|
:options="dayOptions"
|
||||||
|
:max-tag-count="6"
|
||||||
|
placeholder="请选择每月的执行日期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="hasTimeList" label="执行时间">
|
||||||
|
<div class="time-list">
|
||||||
|
<div v-for="(_, index) in formData.times" :key="index" class="time-row">
|
||||||
|
<a-time-picker v-model:value="formData.times[index]" format="HH:mm" :allow-clear="false" />
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
:disabled="formData.times.length <= 1"
|
||||||
|
@click="removeTime(index)"
|
||||||
|
>
|
||||||
|
<template #icon><MinusCircleOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-button type="dashed" block :disabled="formData.times.length >= 10" @click="addTime">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加时间
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'custom'" label="cron 表达式">
|
||||||
|
<a-input v-model:value="formData.expression" placeholder="如 0 3 * * *(分 时 日 月 周)" :maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="执行预览">
|
||||||
|
<a-spin :spinning="previewLoading" size="small">
|
||||||
|
<div class="freq-preview">
|
||||||
|
<a-alert v-if="preview.error" type="warning" :message="preview.error" show-icon />
|
||||||
|
<template v-else>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">表达式</span>
|
||||||
|
<code>{{ preview.cronExpression || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">说明</span>
|
||||||
|
<span>{{ preview.description || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">下次执行</span>
|
||||||
|
<span v-if="!preview.nextRuns.length">-</span>
|
||||||
|
<ol v-else class="next-list">
|
||||||
|
<li v-for="(time, index) in preview.nextRuns" :key="index">{{ formatDateTime(time) }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider v-if="paramFields.length" orientation="left" class="form-divider">任务参数</a-divider>
|
||||||
|
|
||||||
<a-form-item
|
<a-form-item
|
||||||
v-for="field in paramFields"
|
v-for="field in paramFields"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
@@ -149,6 +256,9 @@
|
|||||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
|
||||||
|
|
||||||
<a-form-item label="重试次数" name="retryTimes">
|
<a-form-item label="重试次数" name="retryTimes">
|
||||||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -228,7 +338,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -237,9 +347,18 @@ import {
|
|||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
import {
|
||||||
|
jobApi,
|
||||||
|
type JobItem,
|
||||||
|
type JobParamField,
|
||||||
|
type JobLogItem,
|
||||||
|
type FreqType,
|
||||||
|
type FreqConfig,
|
||||||
|
} from '@/api/job'
|
||||||
import { useTableScroll } from '@/composables/useTableScroll'
|
import { useTableScroll } from '@/composables/useTableScroll'
|
||||||
|
|
||||||
const { wrapperRef, tableHeight } = useTableScroll()
|
const { wrapperRef, tableHeight } = useTableScroll()
|
||||||
@@ -260,7 +379,7 @@ const queryParams = reactive({
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||||||
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
||||||
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
|
{ title: '执行频率', key: 'frequency', width: 200 },
|
||||||
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
||||||
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
||||||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||||||
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
|
|||||||
const paramFields = ref<JobParamField[]>([])
|
const paramFields = ref<JobParamField[]>([])
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
/** HH:mm 文本 → Dayjs(TimePicker 取值),不依赖 dayjs 自定义解析插件 */
|
||||||
|
function toTimeValue(text: string): Dayjs {
|
||||||
|
const [hour, minute] = text.split(':')
|
||||||
|
return dayjs().hour(Number(hour)).minute(Number(minute)).second(0).millisecond(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人性化频率表单:字段按 freqType 取用,提交时由 buildFreqConfig 组装成频率配置 */
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
cronExpression: '',
|
freqType: 'day' as FreqType,
|
||||||
|
interval: 5,
|
||||||
|
minute: 0,
|
||||||
|
times: [toTimeValue('08:00')] as Dayjs[],
|
||||||
|
weekdays: [1] as number[],
|
||||||
|
days: [1] as number[],
|
||||||
|
expression: '',
|
||||||
params: {} as Record<string, any>,
|
params: {} as Record<string, any>,
|
||||||
retryTimes: 0,
|
retryTimes: 0,
|
||||||
retryInterval: 60,
|
retryInterval: 60,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const freqTypeOptions: { value: FreqType; label: string }[] = [
|
||||||
|
{ value: 'minute', label: '每 N 分钟' },
|
||||||
|
{ value: 'hour', label: '每 N 小时' },
|
||||||
|
{ value: 'day', label: '每天' },
|
||||||
|
{ value: 'week', label: '每周' },
|
||||||
|
{ value: 'month', label: '每月' },
|
||||||
|
{ value: 'custom', label: '自定义(cron 表达式)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ value: 1, label: '周一' },
|
||||||
|
{ value: 2, label: '周二' },
|
||||||
|
{ value: 3, label: '周三' },
|
||||||
|
{ value: 4, label: '周四' },
|
||||||
|
{ value: 5, label: '周五' },
|
||||||
|
{ value: 6, label: '周六' },
|
||||||
|
{ value: 0, label: '周日' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dayOptions = Array.from({ length: 31 }, (_, index) => ({
|
||||||
|
value: index + 1,
|
||||||
|
label: `${index + 1} 日`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** day/week/month 需要执行时间点列表 */
|
||||||
|
const hasTimeList = computed(() => ['day', 'week', 'month'].includes(formData.freqType))
|
||||||
|
|
||||||
|
/** 执行预览状态(由后端计算 cron / 中文描述 / 未来执行时间) */
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const preview = reactive({
|
||||||
|
valid: true,
|
||||||
|
error: '',
|
||||||
|
cronExpression: '',
|
||||||
|
description: '',
|
||||||
|
nextRuns: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
cronExpression: [
|
|
||||||
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
|
|
||||||
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
||||||
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
||||||
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 表单 → 频率配置(提交给后端的结构) */
|
||||||
|
function buildFreqConfig(): FreqConfig {
|
||||||
|
switch (formData.freqType) {
|
||||||
|
case 'minute':
|
||||||
|
return { interval: formData.interval }
|
||||||
|
case 'hour':
|
||||||
|
return { interval: formData.interval, minute: formData.minute }
|
||||||
|
case 'day':
|
||||||
|
return { times: timeTexts() }
|
||||||
|
case 'week':
|
||||||
|
return { weekdays: [...formData.weekdays].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
case 'month':
|
||||||
|
return { days: [...formData.days].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
default:
|
||||||
|
return { expression: formData.expression.trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeTexts(): string[] {
|
||||||
|
return formData.times.map(item => item.format('HH:mm'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率配置 → 表单(编辑回填) */
|
||||||
|
function applyFreqConfig(job: JobItem) {
|
||||||
|
const config: FreqConfig = job.freqConfig || {}
|
||||||
|
const type: FreqType = job.freqType || 'custom'
|
||||||
|
|
||||||
|
formData.freqType = type
|
||||||
|
formData.interval = config.interval ?? (type === 'hour' ? 1 : 5)
|
||||||
|
formData.minute = config.minute ?? 0
|
||||||
|
formData.times = (config.times?.length ? config.times : ['08:00']).map(item => toTimeValue(item))
|
||||||
|
formData.weekdays = config.weekdays?.length ? [...config.weekdays] : [1]
|
||||||
|
formData.days = config.days?.length ? [...config.days] : [1]
|
||||||
|
formData.expression = config.expression || job.cronExpression || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端校验之外的本地必填提示,避免空表单直接提交 */
|
||||||
|
function validateFreqConfig(): string {
|
||||||
|
if (hasTimeList.value && !formData.times.length) return '请至少添加 1 个执行时间'
|
||||||
|
if (formData.freqType === 'week' && !formData.weekdays.length) return '请至少选择 1 个星期'
|
||||||
|
if (formData.freqType === 'month' && !formData.days.length) return '请至少选择 1 个日期'
|
||||||
|
if (formData.freqType === 'custom' && !formData.expression.trim()) return '请输入 cron 表达式'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTime() {
|
||||||
|
if (formData.times.length >= 10) return
|
||||||
|
formData.times.push(toTimeValue('12:00'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTime(index: number) {
|
||||||
|
if (formData.times.length <= 1) return
|
||||||
|
formData.times.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
/** 已预览过(且成功过)的频率签名,避免打开弹窗时重复请求 */
|
||||||
|
let previewSignature = ''
|
||||||
|
|
||||||
|
async function fetchPreview() {
|
||||||
|
if (!editVisible.value) return
|
||||||
|
|
||||||
|
const signature = JSON.stringify({ type: formData.freqType, config: buildFreqConfig() })
|
||||||
|
if (signature === previewSignature) return
|
||||||
|
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await jobApi.previewCron({
|
||||||
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
preview.valid = !!res.data.valid
|
||||||
|
preview.error = res.data.error || ''
|
||||||
|
preview.cronExpression = res.data.cronExpression || ''
|
||||||
|
preview.description = res.data.description || ''
|
||||||
|
preview.nextRuns = res.data.nextRuns || []
|
||||||
|
previewSignature = signature
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 预览失败不打断编辑,保存时后端仍会兜底校验
|
||||||
|
previewSignature = ''
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率任一字段变化 → 防抖后重新预览 */
|
||||||
|
watch(
|
||||||
|
() => JSON.stringify({ type: formData.freqType, config: buildFreqConfig() }),
|
||||||
|
() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
previewTimer = setTimeout(fetchPreview, 300)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetPreview() {
|
||||||
|
preview.valid = true
|
||||||
|
preview.error = ''
|
||||||
|
preview.cronExpression = ''
|
||||||
|
preview.description = ''
|
||||||
|
preview.nextRuns = []
|
||||||
|
}
|
||||||
|
|
||||||
const logVisible = ref(false)
|
const logVisible = ref(false)
|
||||||
const logLoading = ref(false)
|
const logLoading = ref(false)
|
||||||
const logList = ref<JobLogItem[]>([])
|
const logList = ref<JobLogItem[]>([])
|
||||||
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
|
|||||||
function handleEdit(record: JobItem) {
|
function handleEdit(record: JobItem) {
|
||||||
currentJob.value = record
|
currentJob.value = record
|
||||||
paramFields.value = record.paramFields || []
|
paramFields.value = record.paramFields || []
|
||||||
formData.cronExpression = record.cronExpression || ''
|
applyFreqConfig(record)
|
||||||
formData.params = { ...(record.params || {}) }
|
formData.params = { ...(record.params || {}) }
|
||||||
formData.retryTimes = record.retryTimes ?? 0
|
formData.retryTimes = record.retryTimes ?? 0
|
||||||
formData.retryInterval = record.retryInterval ?? 60
|
formData.retryInterval = record.retryInterval ?? 60
|
||||||
formData.remark = record.remark || ''
|
formData.remark = record.remark || ''
|
||||||
|
resetPreview()
|
||||||
|
previewSignature = ''
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
nextTick(() => formRef.value?.clearValidate())
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
fetchPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelEdit() {
|
function handleCancelEdit() {
|
||||||
@@ -462,10 +734,22 @@ async function handleSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freqError = validateFreqConfig()
|
||||||
|
if (freqError) {
|
||||||
|
message.warning(freqError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview.valid) {
|
||||||
|
message.warning(preview.error || '频率配置不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||||||
cronExpression: formData.cronExpression,
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
params: { ...formData.params },
|
params: { ...formData.params },
|
||||||
retryTimes: formData.retryTimes,
|
retryTimes: formData.retryTimes,
|
||||||
retryInterval: formData.retryInterval,
|
retryInterval: formData.retryInterval,
|
||||||
@@ -572,6 +856,10 @@ function handleClearLogs() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchJobList()
|
fetchJobList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -635,6 +923,17 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.freq-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.freq-cron {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-stack {
|
.error-stack {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -646,4 +945,51 @@ onMounted(() => {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹窗内容被 teleport 到 body,样式不能嵌套在 .job-management 下 */
|
||||||
|
.form-divider {
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-preview {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #c41d7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+24
-44
@@ -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
|
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
-- 21.1 勘察 SOP 模板检查项表
|
-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
|
||||||
DROP TABLE IF EXISTS survey_sop_item CASCADE;
|
DROP TABLE IF EXISTS survey_detail CASCADE;
|
||||||
CREATE TABLE survey_sop_item (
|
CREATE TABLE survey_detail (
|
||||||
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 (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
||||||
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
|
building_id BIGINT,
|
||||||
checked BOOLEAN NOT NULL DEFAULT FALSE,
|
building_name VARCHAR(128),
|
||||||
receipt_value VARCHAR(1024),
|
floor_no VARCHAR(16),
|
||||||
|
room_id BIGINT,
|
||||||
|
room_name VARCHAR(128),
|
||||||
|
photos TEXT,
|
||||||
remark VARCHAR(255),
|
remark VARCHAR(255),
|
||||||
checked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
|
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
|
||||||
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
|
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
||||||
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
|
COMMENT ON COLUMN survey_detail.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_detail.building_id IS '建筑ID(→ customer_building)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
|
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
|
||||||
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
|
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
|
||||||
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
|
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
|
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
|
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
||||||
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
|
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
||||||
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
|
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
||||||
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
|
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. 安装派单表
|
-- 22. 安装派单表
|
||||||
DROP TABLE IF EXISTS install_order CASCADE;
|
DROP TABLE IF EXISTS install_order CASCADE;
|
||||||
|
|||||||
@@ -333,10 +333,10 @@ SELECT setval('sys_menu_id_seq', 60, true);
|
|||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
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 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
('demo_heartbeat', '示例心跳', '*/5 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
||||||
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
'示例心跳任务:用于验证调度链路,默认每 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 天')
|
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
@@ -370,8 +370,8 @@ SELECT setval('sys_menu_id_seq', 57, true);
|
|||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
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 0 8 * * *', '{}'::jsonb, 1, 300, 1,
|
('notice_scan', '通知规则扫描', '0 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
|
||||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { JobService } from "../service/job.service";
|
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")
|
@Controller("/api/jobs")
|
||||||
export class JobController {
|
export class JobController {
|
||||||
@@ -19,6 +19,12 @@ export class JobController {
|
|||||||
return this.jobService.clearLogs(parsed);
|
return this.jobService.clearLogs(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
|
||||||
|
@Post("/cron/preview")
|
||||||
|
async previewCron(@Body() dto: CronPreviewDTO) {
|
||||||
|
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
|
||||||
|
}
|
||||||
|
|
||||||
@Put("/:id")
|
@Put("/:id")
|
||||||
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
||||||
return this.jobService.update(Number(id), dto);
|
return this.jobService.update(Number(id), dto);
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export class SurveyOrderController {
|
|||||||
|
|
||||||
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||||
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
@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); }
|
@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); }
|
@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); }
|
@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) {
|
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
|
||||||
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
|
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
|
|||||||
@@ -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 { Context } from "@midwayjs/koa";
|
||||||
|
import { UploadFileInfo } from "@midwayjs/upload";
|
||||||
import { WorkerAppService } from "../service/worker_app.service";
|
import { WorkerAppService } from "../service/worker_app.service";
|
||||||
import { NoticeService } from "../service/notice.service";
|
import { NoticeService } from "../service/notice.service";
|
||||||
|
import { UploadService } from "../service/upload.service";
|
||||||
import {
|
import {
|
||||||
WorkerRegisterDTO,
|
WorkerRegisterDTO,
|
||||||
WorkerLoginDTO,
|
WorkerLoginDTO,
|
||||||
@@ -20,6 +22,24 @@ export class WorkerAppController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
noticeService!: NoticeService;
|
noticeService!: NoticeService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
uploadService!: UploadService;
|
||||||
|
|
||||||
|
// 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
|
||||||
|
@Post("/upload/image")
|
||||||
|
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
|
||||||
|
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")
|
@Post("/auth/register")
|
||||||
async register(@Body() body: WorkerRegisterDTO) {
|
async register(@Body() body: WorkerRegisterDTO) {
|
||||||
@@ -130,6 +150,15 @@ export class WorkerAppController {
|
|||||||
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
|
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")
|
@Get("/earnings/summary")
|
||||||
async earningsSummary() {
|
async earningsSummary() {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
|
||||||
export class UpdateJobDTO {
|
export class UpdateJobDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: "cron 表达式不能为空" })
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
cronExpression!: string;
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
|
||||||
|
export class CronPreviewDTO {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export class JobStatusDTO {
|
export class JobStatusDTO {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0, { message: "状态值非法" })
|
@Min(0, { message: "状态值非法" })
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
@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 {
|
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(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
||||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
|
@IsOptional() @IsArray() declare details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
|
|||||||
})
|
})
|
||||||
cronExpression!: string;
|
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<string, any>;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.JSONB,
|
type: DataType.JSONB,
|
||||||
field: "params",
|
field: "params",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import SysJobLog from "../entity/sys_job_log.entity";
|
|||||||
import { JobSchedulerService } from "./job_scheduler.service";
|
import { JobSchedulerService } from "./job_scheduler.service";
|
||||||
import { getBuiltinJob } from "../task/job_registry";
|
import { getBuiltinJob } from "../task/job_registry";
|
||||||
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
|
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
|
||||||
|
import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
|
||||||
|
|
||||||
|
/** 频率预览返回的未来执行次数 */
|
||||||
|
const PREVIEW_RUN_COUNT = 5;
|
||||||
|
|
||||||
@Provide()
|
@Provide()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
@@ -20,6 +24,29 @@ export class JobService {
|
|||||||
@Inject()
|
@Inject()
|
||||||
scheduler!: JobSchedulerService;
|
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<any> {
|
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
|
||||||
const page = Number(query.page) || 1;
|
const page = Number(query.page) || 1;
|
||||||
@@ -45,9 +72,13 @@ export class JobService {
|
|||||||
const list = rows.map(job => {
|
const list = rows.map(job => {
|
||||||
const builtin = getBuiltinJob(job.jobKey);
|
const builtin = getBuiltinJob(job.jobKey);
|
||||||
const registered = !!builtin;
|
const registered = !!builtin;
|
||||||
|
const freq = this.resolveFreq(job);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...job.toJSON(),
|
...job.toJSON(),
|
||||||
|
freqType: freq.freqType,
|
||||||
|
freqConfig: freq.freqConfig,
|
||||||
|
cronDescription: freq.description,
|
||||||
description: builtin ? builtin.description : null,
|
description: builtin ? builtin.description : null,
|
||||||
paramFields: builtin ? builtin.paramFields : [],
|
paramFields: builtin ? builtin.paramFields : [],
|
||||||
registered,
|
registered,
|
||||||
@@ -62,7 +93,7 @@ export class JobService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
/** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
||||||
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
||||||
const job = await this.jobModel.findByPk(id);
|
const job = await this.jobModel.findByPk(id);
|
||||||
|
|
||||||
@@ -75,7 +106,14 @@ export class JobService {
|
|||||||
return { code: 400, message: "任务实现不存在", data: null };
|
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 };
|
return { code: 400, message: "cron 表达式不合法", data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +160,9 @@ export class JobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await job.update({
|
await job.update({
|
||||||
cronExpression: dto.cronExpression,
|
cronExpression,
|
||||||
|
freqType: dto.freqType,
|
||||||
|
freqConfig: schedule.freqConfig ?? {},
|
||||||
params,
|
params,
|
||||||
retryTimes: dto.retryTimes,
|
retryTimes: dto.retryTimes,
|
||||||
retryInterval: dto.retryInterval,
|
retryInterval: dto.retryInterval,
|
||||||
@@ -134,6 +174,37 @@ export class JobService {
|
|||||||
return { code: 200, message: "更新定时任务成功", data: null };
|
return { code: 200, message: "更新定时任务成功", data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频率预览:由「人性化频率配置」计算 cron 表达式、中文描述与未来若干次执行时间。
|
||||||
|
* 配置不完整/表达式非法时仍返回 200,通过 valid/error 让前端就地提示,避免弹窗报错打断填写。
|
||||||
|
*/
|
||||||
|
async previewCron(freqType: string, freqConfig?: Record<string, any>): Promise<any> {
|
||||||
|
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<any> {
|
async changeStatus(id: number, status: number): Promise<any> {
|
||||||
const job = await this.jobModel.findByPk(id);
|
const job = await this.jobModel.findByPk(id);
|
||||||
|
|||||||
@@ -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 天的记录,返回删除条数 */
|
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||||
async cleanJobLogs(keepDays: number): Promise<number> {
|
async cleanJobLogs(keepDays: number): Promise<number> {
|
||||||
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
|
|||||||
import SurveyOrder from "../entity/survey_order.entity";
|
import SurveyOrder from "../entity/survey_order.entity";
|
||||||
import Customer from "../entity/customer.entity";
|
import Customer from "../entity/customer.entity";
|
||||||
import Worker from "../entity/worker.entity";
|
import Worker from "../entity/worker.entity";
|
||||||
import SurveySopItem from "../entity/survey_sop_item.entity";
|
import SurveyDetail from "../entity/survey_detail.entity";
|
||||||
import SurveySopResult from "../entity/survey_sop_result.entity";
|
|
||||||
import DispatchPrice from "../entity/dispatch_price.entity";
|
import DispatchPrice from "../entity/dispatch_price.entity";
|
||||||
import SysUser from "../entity/sysUser.entity";
|
import SysUser from "../entity/sysUser.entity";
|
||||||
import SysUserRole from "../entity/sysUserRole.entity";
|
import SysUserRole from "../entity/sysUserRole.entity";
|
||||||
@@ -18,8 +17,6 @@ import { NoticeRuleService } from "./notice_rule.service";
|
|||||||
|
|
||||||
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
|
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
|
||||||
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 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 缺失时兜底,不写库)
|
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
|
||||||
const DEFAULT_EXPECT_MINUTES = 45;
|
const DEFAULT_EXPECT_MINUTES = 45;
|
||||||
const DEFAULT_BASE_FEE = 350;
|
const DEFAULT_BASE_FEE = 350;
|
||||||
@@ -38,11 +35,8 @@ export class SurveyOrderService {
|
|||||||
@InjectRepository(Worker)
|
@InjectRepository(Worker)
|
||||||
workerModel!: Repository<Worker>;
|
workerModel!: Repository<Worker>;
|
||||||
|
|
||||||
@InjectRepository(SurveySopItem)
|
@InjectRepository(SurveyDetail)
|
||||||
sopItemModel!: Repository<SurveySopItem>;
|
detailModel!: Repository<SurveyDetail>;
|
||||||
|
|
||||||
@InjectRepository(SurveySopResult)
|
|
||||||
sopResultModel!: Repository<SurveySopResult>;
|
|
||||||
|
|
||||||
@InjectRepository(DispatchPrice)
|
@InjectRepository(DispatchPrice)
|
||||||
priceModel!: Repository<DispatchPrice>;
|
priceModel!: Repository<DispatchPrice>;
|
||||||
@@ -257,13 +251,50 @@ export class SurveyOrderService {
|
|||||||
return { baseFee, loopFee, loopCount: count, total };
|
return { baseFee, loopFee, loopCount: count, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按模板批量生成回执记录(checked=false)
|
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
|
||||||
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
|
private normalizeDetails(input: any): any[] {
|
||||||
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
|
if (!Array.isArray(input)) return [];
|
||||||
if (items.length === 0) return;
|
return input
|
||||||
await this.sopResultModel.bulkCreate(
|
.map((row) => {
|
||||||
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
|
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<void> {
|
||||||
|
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<any[]> {
|
||||||
|
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<string>(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<any> {
|
async list(query: any, ctx: Context): Promise<any> {
|
||||||
@@ -342,13 +373,6 @@ export class SurveyOrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// SOP 模板检查项
|
|
||||||
async listSopItems(templateCode?: string): Promise<any> {
|
|
||||||
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<any> {
|
async quote(loopCount?: string): Promise<any> {
|
||||||
const data = await this.calcFee(Number(loopCount) || 0);
|
const data = await this.calcFee(Number(loopCount) || 0);
|
||||||
@@ -362,7 +386,7 @@ export class SurveyOrderService {
|
|||||||
|
|
||||||
const vo = this.toVO(record);
|
const vo = this.toVO(record);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
vo.sopResults = await this.getSopResults(id);
|
vo.details = await this.getDetails(id);
|
||||||
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
||||||
const photoKeys: string[] = vo.photos || [];
|
const photoKeys: string[] = vo.photos || [];
|
||||||
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
||||||
@@ -370,84 +394,17 @@ export class SurveyOrderService {
|
|||||||
return { code: 200, message: "获取勘察派单详情成功", data: vo };
|
return { code: 200, message: "获取勘察派单详情成功", data: vo };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
|
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
|
||||||
private async getSopResults(orderId: number): Promise<any[]> {
|
async saveDetails(id: number, dto: any, ctx: Context): Promise<any> {
|
||||||
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<string>(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<any> {
|
|
||||||
const record = await this.surveyModel.findByPk(id);
|
const record = await this.surveyModel.findByPk(id);
|
||||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, 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 };
|
if (record.status !== STATUS.PENDING_ASSIGN && 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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
await this.replaceDetails(id, dto?.details);
|
||||||
code: 200,
|
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
|
||||||
message: "保存勘察回执成功",
|
|
||||||
data: { updated, skipped, sopResults: await this.getSopResults(id) }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: any, ctx: Context): Promise<any> {
|
async create(data: any, ctx: Context): Promise<any> {
|
||||||
@@ -456,9 +413,8 @@ export class SurveyOrderService {
|
|||||||
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
|
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
|
||||||
if (!isAdmin) data.salesmanId = userId;
|
if (!isAdmin) data.salesmanId = userId;
|
||||||
|
|
||||||
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
|
|
||||||
const payload = this.normalizePayload(data);
|
const payload = this.normalizePayload(data);
|
||||||
delete payload.templateCode;
|
delete payload.details;
|
||||||
|
|
||||||
payload.surveyNo = await this.generateSurveyNo();
|
payload.surveyNo = await this.generateSurveyNo();
|
||||||
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
|
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);
|
const vo = this.toVO(created);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
|
vo.details = await this.getDetails(created.id);
|
||||||
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
|
||||||
return { code: 200, message: "创建勘察派单成功", data: vo };
|
return { code: 200, message: "创建勘察派单成功", data: vo };
|
||||||
}
|
}
|
||||||
@@ -503,13 +463,19 @@ export class SurveyOrderService {
|
|||||||
delete data.salesmanId;
|
delete data.salesmanId;
|
||||||
}
|
}
|
||||||
delete data.surveyNo;
|
delete data.surveyNo;
|
||||||
delete data.templateCode;
|
|
||||||
|
|
||||||
|
const details = data.details;
|
||||||
|
delete data.details;
|
||||||
const payload = this.normalizePayload(data);
|
const payload = this.normalizePayload(data);
|
||||||
await record.update(payload);
|
await record.update(payload);
|
||||||
|
// 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
|
||||||
|
if (Array.isArray(details)) {
|
||||||
|
await this.replaceDetails(id, details);
|
||||||
|
}
|
||||||
const fresh = await this.surveyModel.findByPk(id);
|
const fresh = await this.surveyModel.findByPk(id);
|
||||||
const vo = this.toVO(fresh!);
|
const vo = this.toVO(fresh!);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
|
vo.details = await this.getDetails(id);
|
||||||
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
|
||||||
return { code: 200, message: "更新勘察派单成功", data: vo };
|
return { code: 200, message: "更新勘察派单成功", data: vo };
|
||||||
}
|
}
|
||||||
@@ -643,8 +609,8 @@ export class SurveyOrderService {
|
|||||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
|
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
|
||||||
const surveyNo = record.surveyNo;
|
const surveyNo = record.surveyNo;
|
||||||
// 级联清理回执后删除工单
|
// 级联清理明细后删除工单
|
||||||
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
|
await this.detailModel.destroy({ where: { surveyOrderId: id } });
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
|
||||||
return { code: 200, message: "删除勘察派单成功", data: null };
|
return { code: 200, message: "删除勘察派单成功", data: null };
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
|
|||||||
import InstallOrder from "../entity/install_order.entity";
|
import InstallOrder from "../entity/install_order.entity";
|
||||||
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
||||||
import SurveyOrder from "../entity/survey_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 Customer from "../entity/customer.entity";
|
||||||
import { TokenUtil } from "../util/token";
|
import { TokenUtil } from "../util/token";
|
||||||
|
import { SurveyOrderService } from "./survey_order.service";
|
||||||
import { hashPassword, comparePassword } from "../util/crypto";
|
import { hashPassword, comparePassword } from "../util/crypto";
|
||||||
import {
|
import {
|
||||||
WorkerRegisterDTO,
|
WorkerRegisterDTO,
|
||||||
@@ -69,6 +72,15 @@ export class WorkerAppService {
|
|||||||
@InjectRepository(SurveyOrder)
|
@InjectRepository(SurveyOrder)
|
||||||
surveyModel!: Repository<SurveyOrder>;
|
surveyModel!: Repository<SurveyOrder>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerBuilding)
|
||||||
|
buildingModel!: Repository<CustomerBuilding>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerRoom)
|
||||||
|
roomModel!: Repository<CustomerRoom>;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
surveyOrderService!: SurveyOrderService;
|
||||||
|
|
||||||
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
|
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
sequelize!: Sequelize;
|
sequelize!: Sequelize;
|
||||||
@@ -346,7 +358,51 @@ export class WorkerAppService {
|
|||||||
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
|
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
|
||||||
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
|
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
|
||||||
if (!row) return { code: 404, message: "订单不存在" };
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= 接单 / 完成 =================
|
// ================= 接单 / 完成 =================
|
||||||
|
|||||||
@@ -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<number, string> = {
|
||||||
|
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<number>();
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
const hours = new Set<number>();
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
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<number>();
|
||||||
|
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("、")} 执行`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user