Browse Source

feat: 新增device status图表

曾坤森 1 month ago
parent
commit
e28be6f929

+ 2 - 0
package.json

@@ -36,6 +36,7 @@
     "@vueuse/core": "^13.9.0",
     "axios": "^1.12.2",
     "dayjs": "^1.11.18",
+    "echarts": "^6.0.0",
     "lodash": "^4.17.21",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
@@ -43,6 +44,7 @@
     "query-string": "^9.3.1",
     "sortablejs": "^1.15.6",
     "vue": "^3.5.22",
+    "vue-echarts": "^8.0.1",
     "vue-i18n": "^11.1.12",
     "vue-router": "^4.5.1"
   },

+ 42 - 5
pnpm-lock.yaml

@@ -25,6 +25,9 @@ importers:
       dayjs:
         specifier: ^1.11.18
         version: 1.11.18
+      echarts:
+        specifier: ^6.0.0
+        version: 6.0.0
       lodash:
         specifier: ^4.17.21
         version: 4.17.21
@@ -46,6 +49,9 @@ importers:
       vue:
         specifier: ^3.5.22
         version: 3.5.22(typescript@5.9.2)
+      vue-echarts:
+        specifier: ^8.0.1
+        version: 8.0.1(echarts@6.0.0)(vue@3.5.22(typescript@5.9.2))
       vue-i18n:
         specifier: ^11.1.12
         version: 11.1.12(vue@3.5.22(typescript@5.9.2))
@@ -1980,6 +1986,9 @@ packages:
   duplexer3@0.1.5:
     resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
 
+  echarts@6.0.0:
+    resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
+
   electron-to-chromium@1.5.227:
     resolution: {integrity: sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==}
 
@@ -4909,6 +4918,9 @@ packages:
   tsconfig-paths@3.15.0:
     resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
 
+  tslib@2.3.0:
+    resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
   tslib@2.8.1:
     resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
 
@@ -5144,6 +5156,12 @@ packages:
   vscode-uri@3.1.0:
     resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
 
+  vue-echarts@8.0.1:
+    resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
+    peerDependencies:
+      echarts: ^6.0.0
+      vue: ^3.3.0
+
   vue-eslint-parser@10.2.0:
     resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5274,6 +5292,9 @@ packages:
     resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
     engines: {node: '>=12.20'}
 
+  zrender@6.0.0:
+    resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
+
 snapshots:
 
   '@arco-design/color@0.4.0':
@@ -7207,6 +7228,11 @@ snapshots:
 
   duplexer3@0.1.5: {}
 
+  echarts@6.0.0:
+    dependencies:
+      tslib: 2.3.0
+      zrender: 6.0.0
+
   electron-to-chromium@1.5.227: {}
 
   emoji-regex@10.5.0: {}
@@ -9363,7 +9389,7 @@ snapshots:
     dependencies:
       htmlparser2: 3.10.1
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
 
   postcss-html@1.8.0:
     dependencies:
@@ -9376,7 +9402,7 @@ snapshots:
     dependencies:
       '@babel/core': 7.28.4
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
     transitivePeerDependencies:
       - supports-color
 
@@ -9387,7 +9413,7 @@ snapshots:
   postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39):
     dependencies:
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
       remark: 10.0.1
       unist-util-find-all-after: 1.0.5
 
@@ -9448,7 +9474,7 @@ snapshots:
     dependencies:
       postcss: 8.5.6
 
-  postcss-syntax@0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39):
+  postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39):
     dependencies:
       postcss: 7.0.39
     optionalDependencies:
@@ -10234,7 +10260,7 @@ snapshots:
       postcss-sass: 0.3.5
       postcss-scss: 2.1.1
       postcss-selector-parser: 3.1.2
-      postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss-html@1.8.0)(postcss@8.4.47))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
       postcss-value-parser: 3.3.1
       resolve-from: 4.0.0
       signal-exit: 3.0.7
@@ -10395,6 +10421,8 @@ snapshots:
       minimist: 1.2.8
       strip-bom: 3.0.0
 
+  tslib@2.3.0: {}
+
   tslib@2.8.1: {}
 
   tunnel-agent@0.6.0:
@@ -10690,6 +10718,11 @@ snapshots:
 
   vscode-uri@3.1.0: {}
 
+  vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.22(typescript@5.9.2)):
+    dependencies:
+      echarts: 6.0.0
+      vue: 3.5.22(typescript@5.9.2)
+
   vue-eslint-parser@10.2.0(eslint@9.36.0(jiti@2.6.0)):
     dependencies:
       debug: 4.4.3
@@ -10844,3 +10877,7 @@ snapshots:
   yocto-queue@0.1.0: {}
 
   yocto-queue@1.2.1: {}
+
+  zrender@6.0.0:
+    dependencies:
+      tslib: 2.3.0

+ 2 - 2
src/api/dashboard.ts

@@ -13,8 +13,8 @@ export interface RootObject {
 }
 export interface DataList {
   id?: number;
-  entityType?: number | null;
-  deviceType?: number | null;
+  entityType: number | null;
+  deviceType: number | null;
   name: string;
   address: string;
   status?: number;

+ 0 - 1
src/main.ts

@@ -13,7 +13,6 @@ import App from './App.vue';
 // https://arco.design/docs/designlab/use-theme-package
 import '@/assets/style/global.less';
 import '@/api/interceptor';
-
 const app = createApp(App);
 
 app.use(ArcoVue, {});

+ 330 - 0
src/views/dashboard/workplace/device-info/index.vue

@@ -0,0 +1,330 @@
+<template>
+  <div>
+    <a-modal
+      v-model:visible="visible"
+      width="98%"
+      :title="id ? $t('searchTable.form.edit') : $t('searchTable.form.add')"
+      @cancel="() => handleCancel()"
+    >
+      <template #title>{{ t('dashboard.dialog.title') }}</template>
+      <a-descriptions :data="deviceInfo" bordered :column="2" />
+      <a-form
+        :model="form"
+        auto-label-width
+        ref="formRef"
+        style="margin-top: 16px"
+      >
+        <a-row :gutter="8">
+          <a-col :span="10">
+            <a-form-item
+              field="name"
+              :label="t('dashboard.form.name')"
+              :rules="getRules(t).required"
+            >
+              <a-range-picker
+                showTime
+                :defaultValue="['2019-08-08 00:00:00', '2019-08-18 00:00:00']"
+                @select="onSelect"
+                @change="onChange"
+                :style="{ width: '300px' }"
+              />
+            </a-form-item>
+          </a-col>
+          <a-divider :style="{ height: '33px' }" direction="vertical" />
+          <a-col :flex="'33px'">
+            <a-space :size="10">
+              <a-button type="primary" @click="search">
+                <template #icon>
+                  <icon-search />
+                </template>
+                {{ t('searchTable.form.search') }}
+              </a-button>
+              <a-button @click="reset">
+                <template #icon>
+                  <icon-refresh />
+                </template>
+                {{ t('searchTable.form.reset') }}
+              </a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </a-form>
+      <div class="chart-container">
+        <v-chart
+          ref="chartRef"
+          :option="deviceType === 3 ? sensorChartOptions : chartOptions"
+          autoresize
+          class="chart"
+        />
+      </div>
+      <template #footer>
+        <a-button @click="handleCancel">取消</a-button>
+        <!-- 只保留取消按钮,确认按钮被隐藏 -->
+      </template>
+    </a-modal>
+  </div>
+</template>
+<script setup lang="ts" name="DeviceInfoDialog">
+import { reactive, ref, shallowRef, watch, getCurrentInstance } from 'vue';
+import { getDeviceDetails, saveDeviceDetails } from '@/api/dashboard';
+import type { DataList } from '@/api/dashboard';
+import { useI18n } from 'vue-i18n';
+import { getRules, DeviceInfo } from '@/utils/const';
+import VChart from 'vue-echarts';
+import { useResizeObserver } from '@vueuse/core';
+import { use } from 'echarts/core';
+import { LineChart } from 'echarts/charts';
+import {
+  TitleComponent,
+  TooltipComponent,
+  GridComponent,
+} from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import type { ComposeOption } from 'echarts/core';
+import type { LineSeriesOption } from 'echarts/charts';
+import type {
+  TitleComponentOption,
+  TooltipComponentOption,
+  GridComponentOption,
+} from 'echarts/components';
+
+use([
+  TitleComponent,
+  TooltipComponent,
+  GridComponent,
+  LineChart,
+  CanvasRenderer,
+]);
+
+type EChartsOption = ComposeOption<
+  | TitleComponentOption
+  | TooltipComponentOption
+  | GridComponentOption
+  | LineSeriesOption
+>;
+
+const formRef = ref();
+const { t } = useI18n();
+
+const this_ = getCurrentInstance()?.appContext.config.globalProperties;
+
+interface EditDialogProps {
+  modelValue: boolean;
+  id: number | null;
+  deviceInfo: DeviceInfo[];
+  deviceType: number | null;
+}
+interface EditDialogEmits {
+  (e: 'update:modelValue', value: boolean): void;
+}
+const props = withDefaults(defineProps<EditDialogProps>(), {
+  modelValue: false,
+  id: null,
+  deviceType: 1,
+});
+const emit = defineEmits<EditDialogEmits>();
+const visible = shallowRef<boolean>(false);
+// 响应式图表配置
+const chartOptions = ref<EChartsOption>({
+  title: {
+    text: '24-hour status of equipment',
+    left: 'center',
+  },
+  tooltip: {
+    trigger: 'axis',
+  },
+  grid: {
+    // 关键配置:让图表充分利用空间
+    top: '15%',
+    left: '2%', // 左边距
+    right: '2%', // 右边距
+    bottom: '1%',
+    containLabel: true, // 包含坐标轴标签
+  },
+  xAxis: {
+    type: 'category',
+    data: [
+      '1',
+      '2',
+      '3',
+      '4',
+      '5',
+      '6',
+      '7',
+      '8',
+      '9',
+      '10',
+      '11',
+      '12',
+      '13',
+      '14',
+      '15',
+      '16',
+      '17',
+      '18',
+      '19',
+      '20',
+      '21',
+      '22',
+      '23',
+      '24',
+    ],
+  },
+  yAxis: {
+    type: 'category', // 改为类目轴
+    data: ['Offline', 'Normal', 'Warning', 'Alarm'], // 状态列表
+    boundaryGap: true,
+    axisLabel: {
+      formatter: (value: string) => {
+        // 可以根据需要进行国际化处理
+        return value;
+      },
+    },
+    // 设置 y 轴位置对应关系
+    axisTick: {
+      alignWithLabel: true,
+    },
+  },
+  series: [
+    {
+      name: '状态值',
+      type: 'line',
+      // 数据需要映射到类目轴的索引
+      data: [
+        1, 2, 3, 1, 0, 2, 3, 1, 2, 1, 0, 2, 3, 1, 2, 1, 0, 2, 3, 1, 2, 1, 0, 2,
+      ],
+      smooth: true,
+      // 可以添加不同状态的颜色
+      lineStyle: {
+        width: 3,
+      },
+      itemStyle: {
+        color: (params: any) => {
+          const statusMap: Record<number, string> = {
+            0: '#999999', // Offline - 灰色
+            1: '#52c41a', // Normal - 绿色
+            2: '#faad14', // Warning - 橙色
+            3: '#ff4d4f', // Alarm - 红色
+          };
+          return statusMap[params.data] || '#999999';
+        },
+      },
+    },
+  ],
+});
+// 响应式图表配置
+const sensorChartOptions = ref<EChartsOption>({
+  title: {
+    text: '24-hour status of equipment',
+    left: 'center',
+  },
+  tooltip: {
+    trigger: 'axis',
+  },
+  grid: {
+    // 关键配置:让图表充分利用空间
+    top: '15%',
+    left: '2%', // 左边距
+    right: '2%', // 右边距
+    bottom: '1%',
+    containLabel: true, // 包含坐标轴标签
+  },
+  xAxis: {
+    type: 'category',
+    data: [
+      '1',
+      '2',
+      '3',
+      '4',
+      '5',
+      '6',
+      '7',
+      '8',
+      '9',
+      '10',
+      '11',
+      '12',
+      '13',
+      '14',
+      '15',
+      '16',
+      '17',
+      '18',
+      '19',
+      '20',
+      '21',
+      '22',
+      '23',
+      '24',
+    ],
+  },
+  yAxis: {
+    type: 'value',
+    min: 0, // 设置最小值
+    max: 100, // 设置最大值
+    interval: 10, // 设置刻度间隔
+    axisLabel: {
+      formatter: '{value} °C',
+    },
+  },
+  series: [
+    {
+      name: '温度',
+      type: 'line',
+      data: [
+        20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10, 50, 80, 70, 10, 40, 90,
+        30, 60, 40, 70, 20, 50, 20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10,
+        50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10,
+        50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10,
+        50, 80, 70, 10, 40, 90, 50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10,
+        40, 90, 30, 60, 20, 10, 50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10,
+        40, 90, 30, 60, 20, 10, 50, 80, 70, 10, 40, 90, 0, 80, 70, 10, 40, 90,
+        20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10, 50, 80, 70, 10, 40, 90,
+        50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10,
+        50, 80, 70, 10, 40, 90, 20, 10, 50, 80, 70, 10, 40, 90, 30, 60, 20, 10,
+        50, 80, 70, 10, 40, 90,
+      ],
+      smooth: true,
+    },
+  ],
+});
+watch(
+  () => props.modelValue,
+  value => {
+    visible.value = value;
+    if (value && props.id) {
+      getDeviceDetails({ id: props.id }).then(res => {
+        form.value = res.data;
+      });
+    }
+  }
+);
+const search = () => {};
+const reset = () => {
+  form.value = formModel();
+};
+function onSelect(dateString: any, date: any) {
+  console.log('onSelect', dateString, date);
+}
+function onChange(dateString: any, date: any) {
+  console.log('onChange: ', dateString, date);
+}
+const formModel = () => {
+  return {
+    id: 0,
+    deviceType: null,
+  } as any;
+};
+const form = ref<any>(formModel());
+const handleCancel = () => {
+  form.value = formModel();
+  visible.value = false;
+  emit('update:modelValue', false);
+};
+</script>
+<style lang="less" scoped>
+.chart {
+  width: 100%;
+  height: 300px;
+}
+</style>

+ 13 - 20
src/views/dashboard/workplace/index.vue

@@ -152,20 +152,13 @@
         </template>
       </a-table>
     </a-card>
-    <a-modal
-      v-model:visible="visible"
-      title-align="start"
-      @cancel="handleCancel"
+    <DeviceInfoDialog
+      v-model="showDeviceInfoDialog"
+      :id="deviceId"
+      :deviceInfo="deviceInfo"
+      :deviceType="deviceType"
     >
-      <template #title>{{ t('dashboard.dialog.title') }}</template>
-      <div>
-        <a-descriptions :data="deviceInfo" bordered :column="2" />
-      </div>
-      <template #footer>
-        <a-button @click="handleCancel">取消</a-button>
-        <!-- 只保留取消按钮,确认按钮被隐藏 -->
-      </template>
-    </a-modal>
+    </DeviceInfoDialog>
   </div>
 </template>
 
@@ -191,7 +184,7 @@ import { getDictQueryList } from '@/api/dict';
 import dayjs from 'dayjs';
 import { downLoadFun } from '@/utils/const';
 import { useIntervalFn } from '@vueuse/core';
-
+import DeviceInfoDialog from './device-info/index.vue';
 const { t } = useI18n();
 
 const { loading, setLoading } = useLoading(true);
@@ -234,7 +227,6 @@ const cloneColumns = computed(() => [
     tooltip: true,
   },
 ]);
-
 const basePagination: Pagination = {
   current: 1,
   pageSize: 20,
@@ -258,11 +250,13 @@ const generateFormModel = () => {
 const renderData = ref<DataList[]>([] as DataList[]);
 const size = ref<SizeProps>('medium');
 const formModel = ref<DashboardParams>(generateFormModel());
-const visible = shallowRef<boolean>(false);
+const showDeviceInfoDialog = shallowRef<boolean>(false);
 const this_ = getCurrentInstance()?.appContext.config.globalProperties;
 const deviceInfo = ref<DeviceInfo[]>([] as DeviceInfo[]);
 const deviceTypeList = ref<AdditionalProp[]>([] as AdditionalProp[]);
 const deviceStatusList = ref<AdditionalProp[]>([] as AdditionalProp[]);
+const deviceId = shallowRef<number>(0);
+const deviceType = shallowRef<number | null>(1);
 getDictQueryList({ names: ['DeviceType', 'DeviceStatus'] }).then(res => {
   deviceTypeList.value.push(...res.data['DeviceType']);
   deviceStatusList.value.push(...res.data['DeviceStatus']);
@@ -334,15 +328,14 @@ const handleClick = (value: DataList) => {
         value: obj[key],
       });
     }
-    visible.value = true;
+    console.log('dddd', value.entityType);
+    deviceType.value = value.entityType;
+    showDeviceInfoDialog.value = true;
   } else {
     this_ &&
       this_.$message.warning('No device information available at the moment');
   }
 };
-const handleCancel = () => {
-  visible.value = false;
-};
 </script>
 
 <style lang="less" scoped>