feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
1 parent
f72644c24f
commit
22d27a940d
29 files changed
+1908
-576
No files matched your search
@@ -229,34 +229,135 @@
|
||||
<div class="section-title">
|
||||
<span class="bar bar-secondary"></span>
|
||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||
<h2>第 2 步:勘察技术要点与回执标准</h2>
|
||||
<h2>第 2 步:勘察明细</h2>
|
||||
</div>
|
||||
<div v-if="!isReadonly" class="tmpl-box">
|
||||
<span class="head-hint">SOP规范模板:</span>
|
||||
<button type="button" class="tmpl-btn" @click="applySopTemplate">
|
||||
<PlusOutlined />
|
||||
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
|
||||
</button>
|
||||
<div v-if="!isReadonly" class="head-actions">
|
||||
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
|
||||
<a-button size="small" type="primary" ghost @click="addDetailRow">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加明细
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
|
||||
<div v-for="item in sopItems" :key="item.id" class="sop-card">
|
||||
<div class="sop-top">
|
||||
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
|
||||
<span v-if="item.required" class="sop-required">必填</span>
|
||||
<div class="detail-body">
|
||||
<!-- 编辑态:明细编辑行 -->
|
||||
<template v-if="!isReadonly">
|
||||
<div v-if="detailRows.length === 0" class="detail-empty">
|
||||
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
|
||||
</div>
|
||||
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
|
||||
<div class="sop-foot">
|
||||
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
|
||||
<span>{{ item.receiptRequire || '—' }}</span>
|
||||
<div v-else class="detail-rows">
|
||||
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
|
||||
<div class="detail-row-head">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
|
||||
<PlusOutlined class="sop-add-icon" />
|
||||
<span>+ 自定义勘察检查项</span>
|
||||
</div>
|
||||
<!-- 只读态:明细展示 -->
|
||||
<template v-else>
|
||||
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</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>
|
||||
</section>
|
||||
|
||||
@@ -362,10 +463,6 @@
|
||||
<span class="ro-k">测绘进度</span>
|
||||
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
||||
</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">
|
||||
<span class="ro-k">现场照片</span>
|
||||
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
||||
@@ -387,25 +484,6 @@
|
||||
</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-title">现场取证照片</div>
|
||||
@@ -500,26 +578,27 @@ import {
|
||||
CheckCircleFilled,
|
||||
StarFilled,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
ApartmentOutlined,
|
||||
FileDoneOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
surveyOrderApi,
|
||||
SOP_TEMPLATE_CODE,
|
||||
SOP_TEMPLATE_LABEL,
|
||||
SURVEY_STATUS,
|
||||
SURVEY_PRIORITY,
|
||||
SURVEY_PRIORITY_OPTIONS,
|
||||
SURVEY_BASE_FEE,
|
||||
SURVEY_LOOP_FEE,
|
||||
type SurveySopItem,
|
||||
type SurveyQuote,
|
||||
type SurveyOrderCreateParams,
|
||||
type SurveyOrderInfo,
|
||||
type SurveyOrderDetail,
|
||||
} from '@/api/surveyOrder';
|
||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||
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 route = useRoute();
|
||||
@@ -527,16 +606,10 @@ const route = useRoute();
|
||||
// ==================== 步骤元数据 ====================
|
||||
const STEPS = [
|
||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
|
||||
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||
];
|
||||
|
||||
const RECEIPT_TYPE_LABEL: Record<string, string> = {
|
||||
photo: '现场照片',
|
||||
form: '清单格式',
|
||||
meter: '手持测仪',
|
||||
};
|
||||
|
||||
const SLA_OPTIONS = [
|
||||
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
||||
{ 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 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 roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
||||
const roProgressPercent = computed(() => {
|
||||
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
|
||||
);
|
||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||
|
||||
// ==================== SOP 检查项 ====================
|
||||
const sopItems = ref<SurveySopItem[]>([]);
|
||||
const sopLoading = ref(false);
|
||||
// ==================== 客户空间拓扑 ====================
|
||||
const topology = ref<CustomerTopologyResult | null>(null);
|
||||
const topologyLoading = ref(false);
|
||||
const buildingSelectOptions = computed(() =>
|
||||
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
|
||||
);
|
||||
|
||||
function receiptTypeLabel(type?: string) {
|
||||
if (!type) return '—';
|
||||
return RECEIPT_TYPE_LABEL[type] || type;
|
||||
function buildingById(id?: number): TopologyBuilding | undefined {
|
||||
if (!id) return undefined;
|
||||
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}` : ''}`;
|
||||
}
|
||||
}
|
||||
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
||||
detailRows.value = [];
|
||||
loadTopology();
|
||||
loadWorkers();
|
||||
}
|
||||
|
||||
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
|
||||
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() {
|
||||
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() {
|
||||
const count = Number(formState.loopCount) || 0;
|
||||
try {
|
||||
@@ -802,6 +1002,17 @@ async function loadDetail() {
|
||||
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 {
|
||||
message.error(res.message || '获取勘察派单详情失败');
|
||||
}
|
||||
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
||||
remark: formState.remark.trim() || undefined,
|
||||
source: 'ledger',
|
||||
isDraft,
|
||||
templateCode: SOP_TEMPLATE_CODE,
|
||||
details: buildDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -918,8 +1129,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
await loadCustomers();
|
||||
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
|
||||
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
||||
await loadDetail();
|
||||
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1338,107 +1550,178 @@ onMounted(async () => {
|
||||
background: #0054cd;
|
||||
}
|
||||
|
||||
/* ---------- 第 2 步:SOP ---------- */
|
||||
.tmpl-box {
|
||||
/* ---------- 第 2 步:勘察明细 ---------- */
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmpl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(113, 42, 226, 0.1);
|
||||
color: #712ae2;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tmpl-btn:hover {
|
||||
background: rgba(113, 42, 226, 0.2);
|
||||
}
|
||||
.sop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
.detail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
.sop-grid.is-empty {
|
||||
grid-template-columns: 1fr;
|
||||
.detail-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #5b6b7e;
|
||||
background: #eef4ff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sop-card {
|
||||
.detail-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(219, 228, 238, 0.5);
|
||||
border-radius: 8px;
|
||||
background: #eef4ff;
|
||||
gap: 12px;
|
||||
}
|
||||
.sop-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
.detail-row {
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sop-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sop-required {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sop-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
.sop-foot {
|
||||
.detail-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(219, 228, 238, 0.5);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #eef4ff;
|
||||
border-bottom: 1px solid #e2eaf5;
|
||||
}
|
||||
.detail-row-idx {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
}
|
||||
.sop-add {
|
||||
min-height: 96px;
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
.dfield {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dfield.span-all {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.dfield-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
.dfield-label .opt {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #8a97a8;
|
||||
}
|
||||
.detail-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.detail-photos :deep(.ant-image) {
|
||||
display: block;
|
||||
}
|
||||
.detail-photos :deep(.ant-image img) {
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dbe4ee;
|
||||
}
|
||||
.photo-thumb {
|
||||
position: relative;
|
||||
}
|
||||
.photo-del {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(13, 28, 44, 0.72);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.photo-del:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.photo-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
width: 76px;
|
||||
height: 57px;
|
||||
border: 1px dashed #c2c6d8;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #5b6b7e;
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.sop-add:hover {
|
||||
.photo-add:hover:not(:disabled) {
|
||||
border-color: rgba(0, 84, 205, 0.5);
|
||||
color: #0054cd;
|
||||
}
|
||||
.sop-add-icon {
|
||||
font-size: 22px;
|
||||
.photo-add:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* 只读态明细 */
|
||||
.detail-ro-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.detail-ro-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-ro-scope {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
.detail-ro-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-ro-photos :deep(.ant-image img) {
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.detail-ro-remark {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #5b6b7e;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---------- 第 3 步:电工与报酬 ---------- */
|
||||
@@ -1719,61 +2002,6 @@ onMounted(async () => {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ro-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ro-sop-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 88px 1.2fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
font-size: 12.5px;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
.ro-sop-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ro-sop-check.done {
|
||||
background: #00685f;
|
||||
border-color: #00685f;
|
||||
}
|
||||
.ro-sop-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ro-sop-tag {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #712ae2;
|
||||
}
|
||||
.ro-sop-value {
|
||||
font-size: 11.5px;
|
||||
color: #5b6b7e;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ro-sop-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.ro-sop-thumb :deep(img) {
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dbe4ee;
|
||||
}
|
||||
.ro-photo-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1868,7 +2096,7 @@ onMounted(async () => {
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sop-grid {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cand-grid {
|
||||
|
||||
@@ -514,29 +514,31 @@
|
||||
<div v-else class="detail-text">—</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailRecord.sopResults?.length" class="detail-block">
|
||||
<div class="detail-label">
|
||||
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
|
||||
</div>
|
||||
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
|
||||
<div class="sop-item-head">
|
||||
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
|
||||
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
|
||||
<div v-if="detailRecord.details?.length" class="detail-block">
|
||||
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||
<div class="detail-list">
|
||||
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
|
||||
<div class="detail-row-head">
|
||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||
<span class="detail-row-scope">
|
||||
{{ 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 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>
|
||||
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
|
||||
// ==================== 详情 / 编辑 ====================
|
||||
const detailVisible = ref(false);
|
||||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||||
const detailSopDone = computed(
|
||||
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
|
||||
);
|
||||
|
||||
async function openDetail(record: SurveyOrderInfo) {
|
||||
detailRecord.value = record;
|
||||
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
|
||||
return `${s} ~ ${e}`;
|
||||
}
|
||||
|
||||
const sopChipStyle = (checked?: boolean) =>
|
||||
chipStyle(
|
||||
checked
|
||||
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
|
||||
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
|
||||
);
|
||||
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
|
||||
function floorLabel(floorNo?: string | null) {
|
||||
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||
const n = Number(floorNo);
|
||||
if (Number.isNaN(n)) return String(floorNo);
|
||||
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||
}
|
||||
|
||||
function money(value?: number | string) {
|
||||
if (value == null || value === '') return '—';
|
||||
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sop-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #eef1f6;
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sop-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sop-item-head {
|
||||
.detail-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sop-item-name {
|
||||
.detail-row-idx {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0054cd;
|
||||
}
|
||||
|
||||
.detail-row-scope {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.sop-item-remark {
|
||||
margin-top: 6px;
|
||||
.detail-row-remark {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #8a97a8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* AntD 覆盖 */
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<div class="logo-icon">
|
||||
<ThunderboltOutlined />
|
||||
</div>
|
||||
<h1 class="title">智能电网设备管理系统</h1>
|
||||
<p class="subtitle">Smart Grid IoT Management System</p>
|
||||
<h1 class="title">鑫潼智电管理系统</h1>
|
||||
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
<p>© 2026 智能电网设备管理系统 All Rights Reserved</p>
|
||||
<p>© 2026 鑫潼智电管理系统 All Rights Reserved</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
<span>{{ record.jobName }}</span>
|
||||
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
||||
</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'">
|
||||
<a-tooltip :title="formatParams(record.params, true)">
|
||||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||||
@@ -111,7 +117,7 @@
|
||||
title="编辑任务"
|
||||
:confirm-loading="saving"
|
||||
:mask-closable="false"
|
||||
width="640px"
|
||||
width="720px"
|
||||
@ok="handleSave"
|
||||
@cancel="handleCancelEdit"
|
||||
>
|
||||
@@ -125,9 +131,110 @@
|
||||
<a-form-item label="任务名称">
|
||||
<span>{{ currentJob?.jobName || '-' }}</span>
|
||||
</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 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
|
||||
v-for="field in paramFields"
|
||||
:key="field.key"
|
||||
@@ -149,6 +256,9 @@
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
|
||||
|
||||
<a-form-item label="重试次数" name="retryTimes">
|
||||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||||
</a-form-item>
|
||||
@@ -228,7 +338,7 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
SearchOutlined,
|
||||
@@ -237,9 +347,18 @@ import {
|
||||
PlayCircleOutlined,
|
||||
ProfileOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
jobApi,
|
||||
type JobItem,
|
||||
type JobParamField,
|
||||
type JobLogItem,
|
||||
type FreqType,
|
||||
type FreqConfig,
|
||||
} from '@/api/job'
|
||||
import { useTableScroll } from '@/composables/useTableScroll'
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll()
|
||||
@@ -260,7 +379,7 @@ const queryParams = reactive({
|
||||
const columns = [
|
||||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||||
{ 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: '状态', key: 'status', width: 100, align: 'center' as const },
|
||||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||||
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
|
||||
const paramFields = ref<JobParamField[]>([])
|
||||
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({
|
||||
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>,
|
||||
retryTimes: 0,
|
||||
retryInterval: 60,
|
||||
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 = {
|
||||
cronExpression: [
|
||||
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
|
||||
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
|
||||
],
|
||||
retryTimes: [{ 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' }],
|
||||
}
|
||||
|
||||
/** 表单 → 频率配置(提交给后端的结构) */
|
||||
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 logLoading = ref(false)
|
||||
const logList = ref<JobLogItem[]>([])
|
||||
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
|
||||
function handleEdit(record: JobItem) {
|
||||
currentJob.value = record
|
||||
paramFields.value = record.paramFields || []
|
||||
formData.cronExpression = record.cronExpression || ''
|
||||
applyFreqConfig(record)
|
||||
formData.params = { ...(record.params || {}) }
|
||||
formData.retryTimes = record.retryTimes ?? 0
|
||||
formData.retryInterval = record.retryInterval ?? 60
|
||||
formData.remark = record.remark || ''
|
||||
resetPreview()
|
||||
previewSignature = ''
|
||||
editVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
fetchPreview()
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
@@ -462,10 +734,22 @@ async function handleSave() {
|
||||
return
|
||||
}
|
||||
|
||||
const freqError = validateFreqConfig()
|
||||
if (freqError) {
|
||||
message.warning(freqError)
|
||||
return
|
||||
}
|
||||
|
||||
if (!preview.valid) {
|
||||
message.warning(preview.error || '频率配置不合法')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||||
cronExpression: formData.cronExpression,
|
||||
freqType: formData.freqType,
|
||||
freqConfig: buildFreqConfig(),
|
||||
params: { ...formData.params },
|
||||
retryTimes: formData.retryTimes,
|
||||
retryInterval: formData.retryInterval,
|
||||
@@ -572,6 +856,10 @@ function handleClearLogs() {
|
||||
onMounted(() => {
|
||||
fetchJobList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(previewTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -635,6 +923,17 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.freq-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 20px;
|
||||
|
||||
.freq-cron {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
.error-stack {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
@@ -646,4 +945,51 @@ onMounted(() => {
|
||||
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>
|
||||
Reference in new issue
Block a user