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:
2026-02-12 17:31:31 +08:00
parent a2284d8991
commit b6217a8b02
2 changed files with 564 additions and 4 deletions

View File

@@ -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>

View File

@@ -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>