feat(aiot): 优化算法参数配置界面 - 修复保存失败+精美时间选择器
问题修复:
1. 修复算法参数保存失败问题
- working_hours 数据格式错误(字符串数组 → 对象数组)
- 添加参数校验逻辑
- 改进错误提示(显示后端具体错误信息)
2. 全新时间段选择器组件
- WorkingHoursEditor.vue(精美可视化界面)
- 快捷模板:全天/单班制/两班制/三班制
- 可视化时间选择(TimePicker.RangePicker)
- 智能校验(时间段不重叠、结束>开始)
- 实时预览已配置时间段
核心改进:
1. AlgorithmParamEditor.vue
- 引入 WorkingHoursEditor 组件
- 特殊处理 working_hours 字段
- validateParams() 参数校验
- 改进错误提示(catch块显示详细错误)
- 增加帮助提示
2. WorkingHoursEditor.vue(新建)
- 快捷模板区域:
* 全天监控(空数组)
* 单班制(09:00-18:00)
* 两班制(09:00-12:00, 14:00-18:00)
* 三班制(08:00-16:00, 16:00-00:00, 00:00-08:00)
- 自定义时间段:
* TimePicker.RangePicker 可视化选择
* 时间段卡片展示
* 一键删除、清空全部
* 显示时长(小时+分钟)
- 智能校验:
* 结束时间 > 开始时间
* 时间段不重叠
* 实时错误提示
- 帮助说明:使用提示
UI设计亮点:
- 卡片式布局,清晰直观
- 快捷模板一键应用
- 时间段自动排序
- 空状态优化
- 响应式设计
- 精美的图标和颜色
数据格式:
before: ["09:00-18:00"] // ❌ 字符串数组
after: [{"start": "09:00", "end": "18:00"}] // ✅ 对象数组
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
import { updateAlgoParams } from '#/api/aiot/device';
|
||||
|
||||
import WorkingHoursEditor from './WorkingHoursEditor.vue';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
paramSchema: string;
|
||||
@@ -85,8 +87,20 @@ function removeListItem(key: string, idx: number) {
|
||||
formData.value[key].splice(idx, 1);
|
||||
}
|
||||
|
||||
// 判断是否为工作时间段字段
|
||||
function isWorkingHoursField(key: string): boolean {
|
||||
return key === 'working_hours';
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
// 保存前验证参数
|
||||
const validation = validateParams(formData.value);
|
||||
if (!validation.valid) {
|
||||
message.error(validation.error || '参数格式错误');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAlgoParams({
|
||||
bindId: props.bindId,
|
||||
params: JSON.stringify(formData.value),
|
||||
@@ -94,17 +108,74 @@ async function handleSave() {
|
||||
message.success('参数保存成功');
|
||||
emit('saved', formData.value);
|
||||
emit('update:open', false);
|
||||
} catch {
|
||||
message.error('参数保存失败');
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error);
|
||||
// 改进错误提示:显示后端返回的具体错误信息
|
||||
const errorMsg = error?.response?.data?.msg ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'参数保存失败,请检查参数格式';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 参数校验
|
||||
function validateParams(params: Record<string, any>): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const schema = parsedSchema.value;
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const fieldSchema = schema[key];
|
||||
if (!fieldSchema) continue;
|
||||
|
||||
// 校验整数类型
|
||||
if (fieldSchema.type === 'int') {
|
||||
if (typeof value !== 'number') {
|
||||
return { valid: false, error: `${key} 必须是整数` };
|
||||
}
|
||||
if (fieldSchema.min !== undefined && value < fieldSchema.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${key} 不能小于 ${fieldSchema.min}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 校验列表类型
|
||||
if (fieldSchema.type === 'list') {
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: false, error: `${key} 必须是数组` };
|
||||
}
|
||||
|
||||
// 特殊校验 working_hours 格式
|
||||
if (key === 'working_hours' && value.length > 0) {
|
||||
const isValid = value.every(
|
||||
(item: any) =>
|
||||
typeof item === 'object' &&
|
||||
typeof item.start === 'string' &&
|
||||
typeof item.end === 'string',
|
||||
);
|
||||
if (!isValid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '工作时间段格式错误,每项需包含 start 和 end',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="参数配置"
|
||||
:width="500"
|
||||
title="算法参数配置"
|
||||
:width="700"
|
||||
@cancel="emit('update:open', false)"
|
||||
@ok="handleSave"
|
||||
>
|
||||
@@ -118,6 +189,7 @@ async function handleSave() {
|
||||
:key="key"
|
||||
:label="String(key)"
|
||||
>
|
||||
<!-- 整数类型 -->
|
||||
<InputNumber
|
||||
v-if="schema.type === 'int'"
|
||||
v-model:value="formData[String(key)]"
|
||||
@@ -125,6 +197,14 @@ async function handleSave() {
|
||||
:placeholder="`默认: ${schema.default}`"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<!-- 工作时间段(特殊处理) -->
|
||||
<WorkingHoursEditor
|
||||
v-else-if="isWorkingHoursField(String(key))"
|
||||
v-model="formData[String(key)]"
|
||||
/>
|
||||
|
||||
<!-- 普通列表类型 -->
|
||||
<div v-else-if="schema.type === 'list'">
|
||||
<div style="margin-bottom: 8px">
|
||||
<Tag
|
||||
@@ -154,6 +234,8 @@ async function handleSave() {
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
|
||||
<!-- 字符串类型 -->
|
||||
<Input
|
||||
v-else
|
||||
v-model:value="formData[String(key)]"
|
||||
@@ -161,5 +243,36 @@ async function handleSave() {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<!-- 帮助说明 -->
|
||||
<div class="help-tip">
|
||||
<span class="help-icon">💡</span>
|
||||
<span class="help-text">
|
||||
修改参数后需要重新推送配置到边缘端才能生效
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #e6f4ff;
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
TimePicker,
|
||||
} from 'ant-design-vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface WorkingHourPeriod {
|
||||
start: string; // "HH:mm" 格式
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: WorkingHourPeriod[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [val: WorkingHourPeriod[]];
|
||||
}>();
|
||||
|
||||
// 当前编辑的时间段
|
||||
const currentRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
|
||||
// 内部工作时间段列表
|
||||
const periods = computed<WorkingHourPeriod[]>({
|
||||
get: () => props.modelValue || [],
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
// 快捷模板
|
||||
const templates = [
|
||||
{
|
||||
label: '全天监控',
|
||||
icon: '🌍',
|
||||
description: '24小时不间断',
|
||||
periods: [] as WorkingHourPeriod[], // 空数组表示全天
|
||||
},
|
||||
{
|
||||
label: '单班制',
|
||||
icon: '🏢',
|
||||
description: '09:00-18:00',
|
||||
periods: [{ start: '09:00', end: '18:00' }],
|
||||
},
|
||||
{
|
||||
label: '两班制',
|
||||
icon: '⏰',
|
||||
description: '上午+下午',
|
||||
periods: [
|
||||
{ start: '09:00', end: '12:00' },
|
||||
{ start: '14:00', end: '18:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '三班制',
|
||||
icon: '🔄',
|
||||
description: '早中晚班',
|
||||
periods: [
|
||||
{ start: '08:00', end: '16:00' },
|
||||
{ start: '16:00', end: '00:00' },
|
||||
{ start: '00:00', end: '08:00' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 添加时间段
|
||||
function addPeriod() {
|
||||
if (!currentRange.value) {
|
||||
message.warning('请先选择时间段');
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = currentRange.value;
|
||||
const newPeriod: WorkingHourPeriod = {
|
||||
start: start.format('HH:mm'),
|
||||
end: end.format('HH:mm'),
|
||||
};
|
||||
|
||||
// 校验:结束时间必须晚于开始时间
|
||||
if (newPeriod.start >= newPeriod.end) {
|
||||
message.error('结束时间必须晚于开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验:时间段不能重叠
|
||||
const hasOverlap = periods.value.some((p) => {
|
||||
return !(newPeriod.end <= p.start || newPeriod.start >= p.end);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
message.error('时间段不能重叠');
|
||||
return;
|
||||
}
|
||||
|
||||
periods.value = [...periods.value, newPeriod].sort((a, b) =>
|
||||
a.start.localeCompare(b.start),
|
||||
);
|
||||
currentRange.value = null;
|
||||
message.success('时间段已添加');
|
||||
}
|
||||
|
||||
// 删除时间段
|
||||
function removePeriod(index: number) {
|
||||
periods.value = periods.value.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
// 使用模板
|
||||
function useTemplate(template: (typeof templates)[0]) {
|
||||
periods.value = template.periods;
|
||||
message.success(`已应用模板:${template.label}`);
|
||||
}
|
||||
|
||||
// 清空所有时间段
|
||||
function clearAll() {
|
||||
periods.value = [];
|
||||
}
|
||||
|
||||
// 时间段描述文本
|
||||
function getPeriodText(period: WorkingHourPeriod): string {
|
||||
return `${period.start} - ${period.end}`;
|
||||
}
|
||||
|
||||
// 格式化时长(小时)
|
||||
function getDuration(period: WorkingHourPeriod): string {
|
||||
const [startHour, startMin] = period.start.split(':').map(Number);
|
||||
const [endHour, endMin] = period.end.split(':').map(Number);
|
||||
|
||||
let duration = (endHour - startHour) * 60 + (endMin - startMin);
|
||||
if (duration < 0) duration += 24 * 60; // 跨天
|
||||
|
||||
const hours = Math.floor(duration / 60);
|
||||
const minutes = duration % 60;
|
||||
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="working-hours-editor">
|
||||
<!-- 快捷模板 -->
|
||||
<div class="templates-section">
|
||||
<div class="section-title">
|
||||
<span class="title-icon">⚡</span>
|
||||
<span>快捷模板</span>
|
||||
</div>
|
||||
<div class="template-cards">
|
||||
<Card
|
||||
v-for="template in templates"
|
||||
:key="template.label"
|
||||
class="template-card"
|
||||
:class="{ active: JSON.stringify(periods) === JSON.stringify(template.periods) }"
|
||||
size="small"
|
||||
hoverable
|
||||
@click="useTemplate(template)"
|
||||
>
|
||||
<div class="template-icon">{{ template.icon }}</div>
|
||||
<div class="template-label">{{ template.label }}</div>
|
||||
<div class="template-desc">{{ template.description }}</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义时间段 -->
|
||||
<div class="custom-section">
|
||||
<div class="section-title">
|
||||
<span class="title-icon">⏱️</span>
|
||||
<span>自定义时间段</span>
|
||||
</div>
|
||||
|
||||
<div class="time-picker-row">
|
||||
<TimePicker.RangePicker
|
||||
v-model:value="currentRange"
|
||||
format="HH:mm"
|
||||
:minute-step="30"
|
||||
placeholder="选择开始和结束时间"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<Button type="primary" @click="addPeriod">
|
||||
<template #icon>
|
||||
<span>➕</span>
|
||||
</template>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 已添加的时间段列表 -->
|
||||
<div v-if="periods.length > 0" class="periods-list">
|
||||
<div class="list-header">
|
||||
<span>已配置时间段({{ periods.length }}个)</span>
|
||||
<Button size="small" type="text" danger @click="clearAll">
|
||||
清空全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="period-items">
|
||||
<Card
|
||||
v-for="(period, index) in periods"
|
||||
:key="index"
|
||||
class="period-card"
|
||||
size="small"
|
||||
>
|
||||
<div class="period-content">
|
||||
<div class="period-info">
|
||||
<div class="period-time">
|
||||
<span class="time-icon">🕐</span>
|
||||
<span class="time-text">{{ getPeriodText(period) }}</span>
|
||||
</div>
|
||||
<Tag color="blue" class="duration-tag">
|
||||
{{ getDuration(period) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
@click="removePeriod(index)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">📅</div>
|
||||
<div class="empty-text">暂无时间段配置</div>
|
||||
<div class="empty-hint">
|
||||
选择快捷模板或自定义添加时间段
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 说明提示 -->
|
||||
<div class="help-section">
|
||||
<div class="help-title">💡 使用说明</div>
|
||||
<ul class="help-list">
|
||||
<li>工作时间段为空表示24小时监控</li>
|
||||
<li>时间段不能重叠,系统会自动校验</li>
|
||||
<li>支持跨天时间段(如 16:00-08:00)</li>
|
||||
<li>可添加多个不连续的时间段</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.working-hours-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 模板区域 */
|
||||
.templates-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.template-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.template-card.active {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f4ff;
|
||||
}
|
||||
|
||||
.template-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 自定义区域 */
|
||||
.custom-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.time-picker-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 时间段列表 */
|
||||
.periods-list {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.period-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.period-card {
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.period-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.period-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.period-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.duration-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 帮助说明 */
|
||||
.help-section {
|
||||
background: #e6f4ff;
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0958d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.help-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user