From 08736d6d3f6b39ed570b9bb454f8aaa6147e4cb3 Mon Sep 17 00:00:00 2001 From: mmc <1556375442@qq.com> Date: Mon, 26 Oct 2026 19:30:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=95=8C=E9=9D=A2=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E5=8C=BA=E5=9F=9F=E4=B8=8B=E5=8F=91=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=A7=84=E5=88=99=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/documents/add-rule-config-tab.md | 200 +++ src/api/device.ts | 12 + src/api/rule.tsx | 34 + src/components/Map.jsx | 19 + .../SystemSetting/RuleConfigManagement.tsx | 507 +++++++ .../SystemSetting/SystemMaintain.tsx | 7 +- src/components/devices/MapEditorPage.tsx | 2 +- src/components/devices/RobotTaskPage.tsx | 1207 +++++++++-------- src/components/taskRadio/TaskTypeRadio.css | 161 +++ src/components/taskRadio/taskRadio.tsx | 100 ++ src/locales/en/index.ts | 58 + src/locales/zh/index.ts | 58 + 12 files changed, 1821 insertions(+), 544 deletions(-) create mode 100644 .trae/documents/add-rule-config-tab.md create mode 100644 src/api/rule.tsx create mode 100644 src/components/SystemSetting/RuleConfigManagement.tsx create mode 100644 src/components/taskRadio/TaskTypeRadio.css create mode 100644 src/components/taskRadio/taskRadio.tsx diff --git a/.trae/documents/add-rule-config-tab.md b/.trae/documents/add-rule-config-tab.md new file mode 100644 index 0000000..6fc6022 --- /dev/null +++ b/.trae/documents/add-rule-config-tab.md @@ -0,0 +1,200 @@ +# 在 systemSetting/systemMaintenance 下新增「规则配置」Tab + +## Context + +当前「系统维护」页面已有「故障码管理」「告警设置」「工单设置」三个 Tab。用户希望在 `systemSetting/systemMaintenance` 页面下再新增一个「规则配置」Tab,用于管理场站的自动规则(autoRule)。该功能复用 `src/api/rule.tsx` 中的接口: + +- `selectRuleBySiteId`:`GET /system/autoRule/selectBySiteId` +- `updateTriggerRule`:`POST /system/autoRule/save`(新增/编辑共用,按 `id` 区分) +- `deleteRule`:`POST /system/autoRule/delete` + +后端实体结构(用户提供)包含 `ruleName`、`ruleType`、`deviceScope`、`conditions`(`TriggerConditions` JSON)、`actionConfig`(`ActionConfig` JSON)、`enabled`、`ForceExecute`。 + +## 目标 + +在 `SystemMaintenancePage` 增加「规则配置」Tab,点击后展示 `RuleConfigManagement` 组件:支持按场站查询规则列表、新增/编辑/查看/删除规则。 + +## 方案 + +### 1. 组件拆分 + +新增文件:`src/components/SystemSetting/RuleConfigManagement.tsx` + +复用 `ErrorManagement.tsx` 的成熟模式: +- `Form` 搜索区(规则名称、规则类型、启用状态) +- `Table` 列表展示 +- `Modal` + `Form` 新增/编辑/查看详情 + +### 2. 修改 SystemMaintain.tsx + +在 `src/components/SystemSetting/SystemMaintain.tsx` 中: +- 在 `activeSubTab` 初始化 `useEffect` 的白名单里新增 `'rule'`。 +- 在 `Tabs` 中新增一个 `TabPane`,key 为 `'rule'`,tab 文案使用 `t('systemSetting.ruleConfig')`。 +- 条件渲染 `{activeSubTab === 'rule' && }`。 +- 引入新组件 `RuleConfigManagement`。 + +### 3. 接口调用 + +在 `RuleConfigManagement.tsx` 中引入: + +```tsx +import { + updateTriggerRule, + selectRuleBySiteId, + deleteRule, +} from '@/api/rule'; +``` + +- 列表:`selectRuleBySiteId({ siteId: stationId })`,兼容 `res.data` / `res.rows`。 +- 保存:`updateTriggerRule(submitData)`,包含 `id`(编辑时)、`ruleName`、`ruleType`、`deviceScope`、`conditions`、`actionConfig`、`enabled`、`ForceExecute`、`orgId`、`siteId`。 +- 删除:`deleteRule({ ids: [record.id] })`。 + +### 4. 前端枚举/选项 + +由于用户只给出 Java 实体,前端需要定义以下选项(放在组件内或 `src/lib/utils.ts`): + +- `RuleTypeOptions`: + - `LOW_BATTERY_RETURN`:低电量返航 + - 预留更多 key,方便后续扩展。 +- `DeviceScopeOptions`: + - `ALL`:所有设备 + - `GROUP`:设备组 + - `SOME`:特定的设备 +- `ActionTypeOptions`: + - `AUTO_TASK`:路径规划任务 + - `SEND_COMMAND`:下发指令 + - `ALARM`:告警 + - `WORK_ORDER`:工单下发 +- `TriggerKeyOptions`: + - `battery`:电量 + - `temperature`:温度 + - `offline_duration`:离线时长 + - `voltage`:电压 +- 比较运算符:直接复用现有 `CompareEnumOptions`(EQ/NEQ/GT/LT/GTE/LTE/BETWEEN/NOT_BETWEEN)。 + +> 考虑到项目现有 `CompareEnumOptions` 使用 `i18n.t('constants.xxx')` 作为 label,规则相关枚举 label 也统一使用 `t('systemSetting.xxx')`,并在 `zh/index.ts` 与 `en/index.ts` 的 `systemSetting` 命名空间下补充 key。 + +### 5. 表单设计 + +#### 列表搜索区 +- 规则名称(Input) +- 规则类型(Select) +- 启用状态(Select:启用/禁用) + +#### Modal 表单字段 +- `ruleName`:Input,必填 +- `ruleType`:Select(`RuleTypeOptions`),必填,默认 `LOW_BATTERY_RETURN` +- `deviceScope`:Select(`DeviceScopeOptions`),必填 +- `conditions.key`:Select(`TriggerKeyOptions`) +- `conditions.operator`:Select(`CompareEnumOptions`) +- `conditions.value`:InputNumber / Input +- 若 operator 为区间(`BETWEEN` / `NOT_BETWEEN`),拆分为 `min` / `max`,保存时拼接为 `value`(如 `"20,80"`)。 +- `actionConfig.actionType`:Select(`ActionTypeOptions`) +- `actionConfig.args`:动态表单(根据 `actionType` 显示不同字段)。 + - 第一阶段先提供通用 `Map` 文本域或一组 key-value 输入。 + - 若时间允许,可针对 `AUTO_TASK` 提供「选择路径」下拉。 +- `enabled`:Switch(1/0 或 true/false,按后端实际类型转换) +- `ForceExecute`:Switch + +### 6. 数据结构 + +提交时组装为: + +```tsx +const submitData = { + id: modalType === 'edit' ? currentRecord.id : undefined, + ruleName: values.ruleName, + ruleType: values.ruleType, + deviceScope: values.deviceScope, + conditions: { + key: values.key, + operator: values.operator, + value: values.operator === 'BETWEEN' || values.operator === 'NOT_BETWEEN' + ? `${values.min},${values.max}` + : values.value, + }, + actionConfig: { + actionType: values.actionType, + args: values.args || {}, + }, + enabled: values.enabled ? 1 : 0, // 或 true/false,按后端 Boolean + ForceExecute: values.ForceExecute ? 1 : 0, + orgId: currentStation?.orgId || '', + siteId: stationId || '', +}; +``` + +> 后端 `enabled` / `ForceExecute` 为 `Boolean`,若后端接受 JSON boolean 可直接传 `true/false`;若接受 0/1 则按实际接口调整。 + +### 7. i18n 补充 + +在 `src/locales/zh/index.ts` 与 `src/locales/en/index.ts` 的 `systemSetting` 命名空间下新增: + +```ts +ruleConfig: '规则配置', +ruleName: '规则名称', +ruleType: '规则类型', +ruleTypeLowBatteryReturn: '低电量返航', +deviceScope: '适用范围', +deviceScopeAll: '所有设备', +deviceScopeGroup: '设备组', +deviceScopeSome: '特定设备', +triggerKey: '触发字段', +triggerKeyBattery: '电量', +triggerKeyTemperature: '温度', +triggerKeyOfflineDuration: '离线时长', +triggerKeyVoltage: '电压', +operator: '比较运算符', +triggerValue: '阈值', +actionType: '执行动作', +actionTypeAutoTask: '路径规划任务', +actionTypeSendCommand: '下发指令', +actionTypeAlarm: '告警', +actionTypeWorkOrder: '工单下发', +actionArgs: '动作参数', +enabled: '是否启用', +forceExecute: '强制执行', +pleaseInputRuleName: '请输入规则名称', +pleaseSelectRuleType: '请选择规则类型', +pleaseSelectDeviceScope: '请选择适用范围', +pleaseSelectTriggerKey: '请选择触发字段', +pleaseSelectOperator: '请选择比较运算符', +pleaseInputTriggerValue: '请输入阈值', +pleaseSelectActionType: '请选择执行动作', +ruleList: '规则列表', +addRule: '新增规则', +editRule: '编辑规则', +viewRule: '查看规则', +``` + +### 8. 表格列 + +| 列名 | 字段 | 说明 | +|---|---|---| +| 规则名称 | ruleName | - | +| 规则类型 | ruleType | 使用 `RuleTypeOptions` 渲染 label | +| 适用范围 | deviceScope | 使用 `DeviceScopeOptions` 渲染 | +| 触发条件 | conditions | 渲染为 `key operator value` 文本 | +| 执行动作 | actionConfig.actionType | 使用 `ActionTypeOptions` 渲染 | +| 启用 | enabled | Switch 或 Tag | +| 强制 | ForceExecute | Tag | +| 操作 | action | 查看/编辑/删除 | + +## 关键文件 + +- `src/components/SystemSetting/SystemMaintain.tsx`:新增 Tab 入口 +- `src/components/SystemSetting/RuleConfigManagement.tsx`:新增规则配置组件 +- `src/api/rule.tsx`:复用已有接口 +- `src/locales/zh/index.ts`:中文 i18n +- `src/locales/en/index.ts`:英文 i18n + +## 验证 + +1. 启动项目,进入「系统设置」→「系统维护」。 +2. 确认出现「规则配置」Tab。 +3. 列表为空时显示空状态;若已有规则则正确展示。 +4. 点击「新增」打开弹窗,填写后保存,调用 `/system/autoRule/save`。 +5. 编辑已有规则,确认表单回填正确(区间值拆分为 min/max)。 +6. 删除规则,确认调用 `/system/autoRule/delete`。 +7. 切换站点后,列表按 `siteId` 重新加载。 +8. 运行 `npx esbuild src/components/SystemSetting/RuleConfigManagement.tsx --outfile=_tmp_rule.js --log-level=warning` 校验语法。 diff --git a/src/api/device.ts b/src/api/device.ts index 4d9bdd9..5fed4d8 100644 --- a/src/api/device.ts +++ b/src/api/device.ts @@ -489,6 +489,18 @@ export function downloadDeviceTemplate(params) { } +///iot/deviceTask/createRegionDefaultTask 下发区域默认任务 + +export function createRegionDefaultTask(params) { + return request({ + url: '/iot/deviceTask/createRegionDefaultTask', + method: 'get', + params, + }) +} + + + diff --git a/src/api/rule.tsx b/src/api/rule.tsx new file mode 100644 index 0000000..7d123c8 --- /dev/null +++ b/src/api/rule.tsx @@ -0,0 +1,34 @@ +import request from './request'; + +// ==================== 机器人类型 ==================== + +// 修改触发规则 +export function updateTriggerRule(data) { + return request({ + url: '/system/autoRule/save', + method: 'post', + data + }); +} + +//获取场站规则/system/autoRule/selectBySiteId + +export function selectRuleBySiteId(data) { + return request({ + url: '/system/autoRule/selectBySiteId', + method: 'get', + params: data + }); +} + +//删除规则/system/autoRule/delete + +export function deleteRule(data) { + return request({ + url: '/system/autoRule/delete', + method: 'post', + data + }); +} + + diff --git a/src/components/Map.jsx b/src/components/Map.jsx index 4f995c3..af753a5 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -1280,6 +1280,9 @@ const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine, if (viewerRef.current) return; const viewer = new window.Cesium.Viewer(containerRef.current, { + useBrowserRecommendedResolution: false, + + animation: false, timeline: false, baseLayerPicker: true, @@ -1294,6 +1297,8 @@ const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine, fileExtension: "jpg" }) }); + viewer.resolutionScale = 2.0; + viewerRef.current = viewer; viewer.scene.globe.baseColor = window.Cesium?.Color.BLACK; viewer.scene.globe.depthTestAgainstTerrain = false; @@ -1485,6 +1490,20 @@ const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine, ), duration: 0.8, }); + } else if (focusOverlay.type === 'route') { + // 飞到路线的边界框 + const pts = Array.isArray(focusOverlay.points) ? focusOverlay.points : []; + const lons = pts.map(p => Number(p?.lng ?? p?.lon)).filter(n => isFinite(n) && n !== 0); + const lats = pts.map(p => Number(p?.lat)).filter(n => isFinite(n) && n !== 0); + if (!lons.length || !lats.length) return; + setLegendHighlight(''); + setHighlightReturnPointId(''); + viewer.camera.flyTo({ + destination: Cesium.Rectangle.fromDegrees( + Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats) + ), + duration: 0.8, + }); } }, [focusOverlay, stationOverlay]); diff --git a/src/components/SystemSetting/RuleConfigManagement.tsx b/src/components/SystemSetting/RuleConfigManagement.tsx new file mode 100644 index 0000000..811a694 --- /dev/null +++ b/src/components/SystemSetting/RuleConfigManagement.tsx @@ -0,0 +1,507 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Table, + Input, + Button, + Form, + Select, + Space, + Tag, + Modal, + Card, + Typography, + Switch, + InputNumber, + Row, + Col, + App, +} from 'antd'; +import { + SearchOutlined, + ReloadOutlined, + PlusOutlined, + EditOutlined, + DeleteOutlined, + EyeOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import { + updateTriggerRule, + selectRuleBySiteId, + deleteRule, +} from '@/api/rule'; +import utils, { CompareEnumOptions } from '@/lib/utils'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/src/store'; +import { useTranslation } from 'react-i18next'; + +const { TextArea } = Input; +const { Text } = Typography; +const { Option } = Select; + +const AntdCard = Card as any; + +// 规则类型 +const RuleTypeOptions = [ + { value: 'LOW_BATTERY_RETURN', labelKey: 'ruleTypeLowBatteryReturn' }, +]; + +// 适用范围 +const DeviceScopeOptions = [ + { value: 'ALL', labelKey: 'deviceScopeAll' }, + { value: 'GROUP', labelKey: 'deviceScopeGroup' }, + { value: 'SOME', labelKey: 'deviceScopeSome' }, +]; + +// 触发字段 +const TriggerKeyOptions = [ + { value: 'battery', labelKey: 'triggerKeyBattery' }, + { value: 'temperature', labelKey: 'triggerKeyTemperature' }, + { value: 'offline_duration', labelKey: 'triggerKeyOfflineDuration' }, + { value: 'voltage', labelKey: 'triggerKeyVoltage' }, +]; + +// 执行动作 +const ActionTypeOptions = [ + { value: 'AUTO_TASK', labelKey: 'actionTypeAutoTask' }, + { value: 'SEND_COMMAND', labelKey: 'actionTypeSendCommand' }, + { value: 'ALARM', labelKey: 'actionTypeAlarm' }, + { value: 'WORK_ORDER', labelKey: 'actionTypeWorkOrder' }, +]; + +const rangeOperators = ['BETWEEN', 'NOT_BETWEEN']; + +export default function RuleConfigManagement() { + const { t } = useTranslation(); + const { stationId, currentStation } = useSelector((state: RootState) => state.station); + const { modal } = App.useApp(); + const [form] = Form.useForm(); + const [searchForm] = Form.useForm(); + + const [loading, setLoading] = useState(false); + const [ruleList, setRuleList] = useState([]); + const [filteredList, setFilteredList] = useState([]); + + const [modalVisible, setModalVisible] = useState(false); + const [modalType, setModalType] = useState<'add' | 'edit' | 'view'>('add'); + const [currentRecord, setCurrentRecord] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // 获取规则列表 + const fetchRuleList = async () => { + if (!stationId) return; + setLoading(true); + try { + const res = await selectRuleBySiteId({ siteId: stationId }); + const list = res?.data || res?.rows || []; + setRuleList(list); + setFilteredList(list); + } catch (err) { + utils.message.error(t('systemSetting.operationFailed')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRuleList(); + }, [stationId]); + + // 搜索过滤 + const handleSearch = () => { + searchForm.validateFields().then(values => { + const next = ruleList.filter(item => { + const matchName = !values.ruleName || (item.ruleName || '').toLowerCase().includes(values.ruleName.toLowerCase()); + const matchType = !values.ruleType || item.ruleType === values.ruleType; + const matchEnabled = values.enabled === undefined || values.enabled === '' || Number(item.enabled) === Number(values.enabled); + return matchName && matchType && matchEnabled; + }); + setFilteredList(next); + }); + }; + + const handleReset = () => { + searchForm.resetFields(); + setFilteredList(ruleList); + }; + + // 打开弹窗 + const openModal = (type: 'add' | 'edit' | 'view', record?: any) => { + setModalType(type); + setCurrentRecord(record || null); + setModalVisible(true); + form.resetFields(); + + if ((type === 'edit' || type === 'view') && record) { + const conditions = record.conditions || {}; + const actionConfig = record.actionConfig || {}; + const isRange = rangeOperators.includes(conditions.operator); + let min = ''; + let max = ''; + let value = conditions.value; + if (isRange && typeof conditions.value === 'string' && conditions.value.includes(',')) { + const [a, b] = conditions.value.split(','); + min = a; + max = b; + value = ''; + } + let argsJson = ''; + try { + argsJson = actionConfig.args ? JSON.stringify(actionConfig.args, null, 2) : ''; + } catch (e) { + argsJson = ''; + } + form.setFieldsValue({ + ruleName: record.ruleName, + ruleType: record.ruleType, + deviceScope: record.deviceScope, + key: conditions.key, + operator: conditions.operator, + value, + min, + max, + actionType: actionConfig.actionType, + argsJson, + enabled: record.enabled === true || record.enabled === 1, + ForceExecute: record.ForceExecute === true || record.ForceExecute === 1, + }); + } else { + form.setFieldsValue({ + ruleType: 'LOW_BATTERY_RETURN', + deviceScope: 'ALL', + key: 'battery', + operator: 'LT', + actionType: 'AUTO_TASK', + enabled: true, + ForceExecute: false, + }); + } + }; + + // 保存规则 + const handleSave = async (values: any) => { + let argsObj: any = {}; + if (values.argsJson) { + try { + argsObj = JSON.parse(values.argsJson); + } catch (e) { + utils.message.error(t('systemSetting.invalidJson')); + return; + } + } + + const isRange = rangeOperators.includes(values.operator); + const value = isRange ? `${values.min},${values.max}` : values.value; + + const submitData: any = { + id: modalType === 'edit' ? currentRecord?.id : undefined, + ruleName: values.ruleName, + ruleType: values.ruleType, + deviceScope: values.deviceScope, + conditions: { + key: values.key, + operator: values.operator, + value, + }, + actionConfig: { + actionType: values.actionType, + args: argsObj, + }, + enabled: values.enabled ? 1 : 0, + ForceExecute: values.ForceExecute ? 1 : 0, + orgId: currentStation?.orgId || '', + siteId: stationId || '', + }; + + setSubmitting(true); + try { + const res = await updateTriggerRule(submitData); + if (res?.code === 200) { + utils.message.success(modalType === 'edit' ? t('common.editSuccess') : t('common.addSuccess')); + setModalVisible(false); + fetchRuleList(); + } else { + utils.message.error(res?.msg || t('systemSetting.operationFailed')); + } + } catch (err) { + utils.message.error(t('systemSetting.operationFailed')); + } finally { + setSubmitting(false); + } + }; + + // 删除规则 + const handleDelete = (record: any) => { + modal.confirm({ + title: t('common.confirmDelete'), + content: t('systemSetting.confirmDeleteRule', { name: record.ruleName }), + icon: , + okText: t('common.ok'), + cancelText: t('common.cancel'), + onOk: async () => { + try { + const res = await deleteRule({ ids: [record.id] }); + if (res?.code === 200) { + utils.message.success(t('common.deleteSuccess')); + fetchRuleList(); + } else { + utils.message.error(res?.msg || t('common.deleteFail')); + } + } catch (err) { + utils.message.error(t('common.deleteFail')); + } + }, + }); + }; + + const operatorWatch = Form.useWatch('operator', form); + const isRangeOperator = useMemo(() => rangeOperators.includes(operatorWatch), [operatorWatch]); + + const getLabel = (options: any[], value: string, ns: string = 'systemSetting') => { + const item = options.find(o => o.value === value); + return item ? t(`${ns}.${item.labelKey}`) : value; + }; + + const renderConditions = (record: any) => { + const c = record.conditions || {}; + const opItem = CompareEnumOptions.find(o => o.value === c.operator); + const opLabel = opItem ? t(`constants.${opItem.label}`) : c.operator; + return {c.key} {opLabel} {c.value}; + }; + + const columns = [ + { + title: t('systemSetting.ruleName'), + dataIndex: 'ruleName', + key: 'ruleName', + width: 180, + fixed: 'left' as const, + }, + { + title: t('systemSetting.ruleType'), + dataIndex: 'ruleType', + key: 'ruleType', + width: 140, + render: (text: string) => getLabel(RuleTypeOptions, text), + }, + { + title: t('systemSetting.deviceScope'), + dataIndex: 'deviceScope', + key: 'deviceScope', + width: 120, + render: (text: string) => getLabel(DeviceScopeOptions, text), + }, + { + title: t('systemSetting.triggerKey'), + key: 'conditions', + width: 220, + render: (_: any, record: any) => renderConditions(record), + }, + { + title: t('systemSetting.actionType'), + dataIndex: ['actionConfig', 'actionType'], + key: 'actionType', + width: 140, + render: (text: string) => getLabel(ActionTypeOptions, text), + }, + { + title: t('systemSetting.enabledLabel'), + dataIndex: 'enabled', + key: 'enabled', + width: 100, + render: (val: any) => {val === 1 || val === true ? t('common.enable') : t('common.disable')}, + }, + { + title: t('systemSetting.forceExecute'), + dataIndex: 'ForceExecute', + key: 'ForceExecute', + width: 100, + render: (val: any) => {val === 1 || val === true ? t('common.yes') : t('common.no')}, + }, + { + title: t('common.actions'), + key: 'action', + width: 220, + fixed: 'right' as const, + render: (_: any, record: any) => ( + + + + + + ), + }, + ]; + + const disabledForm = modalType === 'view'; + + return ( +
+ +
+ + + + + + + + + + + + + + + + +
+ + t('common.total', { total }) }} + /> + + + setModalVisible(false)} + footer={ + disabledForm ? ( + + ) : ( + + + + + ) + } + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {isRangeOperator ? ( + + + + + + + + + + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + +