jiaxing.liao недель назад: 2
Родитель
Сommit
8791759db4

+ 4 - 4
src/renderer/src/lvgl-widgets/image/Image.vue

@@ -27,11 +27,11 @@ const props = defineProps<{
   styles: any
   part?: string
   state?: string
-  center: {
+  pivot: {
     x: number
     y: number
   }
-  rotate: number
+  rotation: number
   openScale?: boolean
   scale?: number
   antiAliasing?: boolean
@@ -47,9 +47,9 @@ const styleMap = useWidgetStyle({
 })
 
 const imageProps = computed(() => {
-  const { openScale, scale = 256, width, height, center } = props
+  const { openScale, scale = 256, width, height, pivot } = props
   const s = openScale ? scale / 256 : 1
-  const { x = width / 2, y = height / 2 } = center
+  const { x = width / 2, y = height / 2 } = pivot
 
   return {
     width: `${width}px`,

+ 5 - 5
src/renderer/src/lvgl-widgets/image/index.ts

@@ -30,11 +30,11 @@ export default {
       flags: [],
       states: [],
       image: '',
-      center: {
+      pivot: {
         x: 50,
         y: 50
       },
-      rotate: 0,
+      rotation: 0,
       openScale: false,
       scale: 256
       // antiAliasing: false
@@ -201,7 +201,7 @@ export default {
         children: [
           {
             label: 'X',
-            field: 'props.center.x',
+            field: 'props.pivot.x',
             valueType: 'number',
             componentProps: {
               span: 12
@@ -209,7 +209,7 @@ export default {
           },
           {
             label: 'Y',
-            field: 'props.center.y',
+            field: 'props.pivot.y',
             valueType: 'number',
             componentProps: {
               span: 12
@@ -219,7 +219,7 @@ export default {
       },
       {
         label: '旋转角度',
-        field: 'props.rotate',
+        field: 'props.rotation',
         labelWidth: '60px',
         valueType: 'number',
         componentProps: {

+ 131 - 87
src/renderer/src/lvgl-widgets/scale/Config.vue

@@ -1,65 +1,101 @@
 <template>
   <div>
-    <el-button type="primary" size="small" @click="onAdd">{{
-      $t?.('添加区域') ?? '添加区域'
-    }}</el-button>
-    <div
-      v-for="(section, idx) in sections"
-      :key="section.name"
-      class="section-config"
-      style="margin-top: 16px; border: 1px solid #eee; padding: 12px; border-radius: 4px"
+    <el-card
+      :body-class="!areaList.length ? 'hidden' : 'pr-0!'"
+      :header-class="!areaList.length ? 'border-b-none!' : ''"
+      class="mb-12px"
     >
-      <div style="display: flex; align-items: center; justify-content: space-between">
-        <div>
-          <b>{{ section.name }}</b>
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>区域</span>
+          <span class="flex gap-4px">
+            <LuPlus class="cursor-pointer" @click="handleAdd" size="14px" />
+            <LuTrash2 class="cursor-pointer" @click="handleClear" size="14px" />
+          </span>
         </div>
-        <el-button
-          v-if="sections.length > 1"
-          type="danger"
-          size="small"
-          icon="el-icon-delete"
-          @click="onRemove(idx)"
-          circle
-        />
-      </div>
-      <el-form :inline="true" label-width="80px" style="margin-top: 12px">
-        <el-form-item :label="$t?.('起始值') ?? '起始值'">
+      </template>
+
+      <el-scrollbar height="120px" v-if="areaList.length > 0">
+        <div
+          v-for="(item, index) in areaList"
+          :key="v4()"
+          class="flex items-center pr-12px"
+          @click="handleEdit(item, index)"
+        >
+          <span class="flex-1 truncate text-#00ff00 cursor-pointer">
+            {{ item.name || `area_${index}` }} ({{ item.start }}-{{ item.end }})
+          </span>
+          <LuTrash2 class="cursor-pointer shrink-0" @click.stop="handleDelete(index)" size="14px" />
+        </div>
+      </el-scrollbar>
+    </el-card>
+
+    <el-dialog v-model="dialogVisible" title="编辑区域" width="440px">
+      <el-form :model="formData" label-position="left" label-width="80px">
+        <el-form-item :label="'名称'">
+          <el-input v-model="formData.name" spellcheck="false" placeholder="area_x" />
+        </el-form-item>
+
+        <el-form-item :label="'起始值'">
           <input-number
-            v-model="section.start"
+            v-model="formData.start"
+            controls-position="right"
             :min="0"
             :max="10000"
             :step="1"
-            size="small"
-            @change="emitChange"
-          ></input-number>
+            style="width: 100%"
+          />
         </el-form-item>
-        <el-form-item :label="$t?.('结束值') ?? '结束值'">
+
+        <el-form-item :label="'结束值'">
           <input-number
-            v-model="section.end"
+            v-model="formData.end"
+            controls-position="right"
             :min="1"
             :max="10000"
             :step="1"
-            size="small"
-            @change="emitChange"
-          ></input-number>
+            style="width: 100%"
+          />
         </el-form-item>
-        <el-form-item :label="$t?.('文本颜色') ?? '文本颜色'">
-          <ColorPicker v-model="section.textColor" use-type="pure" format="hex8" />
-          <span class="text-text-active">{{ section.textColor }}</span>
+
+        <el-form-item :label="'文本颜色'">
+          <ColorPicker
+            v-model:pureColor="formData.textColor"
+            format="hex8"
+            picker-type="chrome"
+            use-type="pure"
+          />
+          <span class="text-text-active">{{ formData.textColor }}</span>
         </el-form-item>
-        <el-form-item :label="$t?.('直线颜色') ?? '直线颜色'">
-          <ColorPicker v-model="section.lineColor" use-type="pure" format="hex8" />
-          <span class="text-text-active">{{ section.lineColor }}</span>
+
+        <el-form-item :label="'直线颜色'">
+          <ColorPicker
+            v-model:pureColor="formData.lineColor"
+            format="hex8"
+            picker-type="chrome"
+            use-type="pure"
+          />
+          <span class="text-text-active">{{ formData.lineColor }}</span>
         </el-form-item>
       </el-form>
-    </div>
+
+      <template #footer>
+        <el-button type="primary" @click="submit">
+          {{ '确定' }}
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue'
+import { computed, type Ref, ref } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { v4 } from 'uuid'
+import { getNextIndex } from '@/utils'
 
 import { ColorPicker } from '@/components'
+import { klona } from 'klona'
 
 export type AreaSection = {
   name: string
@@ -70,62 +106,70 @@ export type AreaSection = {
 }
 
 const props = defineProps<{
-  values: AreaSection[]
+  values: Ref<AreaSection[]>
 }>()
-const emit = defineEmits(['update:modelValue', 'change'])
 
-const defaultSection = (idx: number) => ({
-  name: `curSection_${idx}`,
+const dialogVisible = ref(false)
+const formData = ref<AreaSection>({
+  name: '',
   start: 0,
   end: 20,
-  textColor: '#0000ff',
-  lineColor: '#0000ff'
+  textColor: '#000000ff',
+  lineColor: '#000000ff'
 })
 
-const sections = ref<any[]>(
-  Array.isArray(props.values) && props.values.length
-    ? props.values.map((s, idx) => ({
-        name: s.name || `curSection_${idx}`,
-        start: typeof s.start === 'number' ? s.start : 0,
-        end: typeof s.end === 'number' ? s.end : 20,
-        textColor: s.textColor ?? '#000000ff',
-        lineColor: s.lineColor ?? '#000000ff'
-      }))
-    : [defaultSection(0)]
-)
-
-function onAdd() {
-  sections.value.push(defaultSection(sections.value.length))
-  emitChange()
+const areaList = computed({
+  get() {
+    return (props.values?.value || []) as AreaSection[]
+  },
+  set(list: AreaSection[]) {
+    props.values.value = list
+  }
+})
+
+/**
+ * 添加区域
+ */
+const handleAdd = () => {
+  const newIndex = getNextIndex(areaList.value, 'name')
+  areaList.value.push({
+    name: `area_${newIndex}`,
+    start: 0,
+    end: 20,
+    textColor: '#000000ff',
+    lineColor: '#000000ff'
+  })
 }
-function onRemove(idx: number) {
-  sections.value.splice(idx, 1)
-  emitChange()
+
+/**
+ * 删除区域
+ */
+const handleDelete = (index: number) => {
+  areaList.value.splice(index, 1)
 }
-function emitChange() {
-  const output = sections.value.map((section) => ({
-    name: section.name,
-    start: section.start,
-    end: section.end,
-    textColor: section.textColor,
-    lineColor: section.lineColor
-  }))
-  emit('update:modelValue', output)
-  emit('change', output)
+
+/**
+ * 清空区域
+ */
+const handleClear = () => {
+  areaList.value = []
 }
 
-watch(
-  () => props.values,
-  (val) => {
-    if (Array.isArray(val)) {
-      sections.value = val.map((s: any, idx: number) => ({
-        name: s.name || `curSection_${idx}`,
-        start: typeof s.start === 'number' ? s.start : 0,
-        end: typeof s.end === 'number' ? s.end : 20,
-        textColor: s.textColor ?? '#000000ff',
-        lineColor: s.lineColor ?? '#000000ff'
-      }))
-    }
-  }
-)
+let tempIndex
+/**
+ * 编辑区域(直接引用修改)
+ */
+const handleEdit = (record: AreaSection, index: number) => {
+  formData.value = klona(record)
+  dialogVisible.value = true
+  tempIndex = index
+}
+
+/**
+ * 提交
+ */
+const submit = () => {
+  areaList.value[tempIndex] = klona(formData.value)
+  dialogVisible.value = false
+}
 </script>

+ 131 - 58
src/renderer/src/lvgl-widgets/scale/Scale.vue

@@ -3,7 +3,7 @@
     :style="{
       ...styleMap?.mainStyle
     }"
-    class="relative w-full h-full box-border relative"
+    class="relative w-full h-full box-border overflow-visible"
   >
     <ImageBg
       v-if="styleMap?.mainStyle?.imageSrc"
@@ -11,26 +11,14 @@
       :imageStyle="styleMap?.mainStyle?.imageStyle"
     />
 
-    <div class="absolute inset-0 w-full h-full">
+    <div class="absolute inset-0 w-full h-full overflow-visible">
       <svg
         :viewBox="`0 0 ${width} ${height}`"
         preserveAspectRatio="xMidYMid meet"
-        class="w-full h-full block"
+        class="w-full h-full block overflow-visible"
         xmlns="http://www.w3.org/2000/svg"
       >
         <defs></defs>
-        <!-- 主轴直线:按 area 分段绘制,每段使用对应范围的直线颜色 -->
-        <line
-          v-for="(seg, segIdx) in axisSegments"
-          :key="'axis-' + segIdx"
-          :x1="seg.x1"
-          :y1="seg.y1"
-          :x2="seg.x2"
-          :y2="seg.y2"
-          :stroke="seg.stroke"
-          :stroke-width="mainLineWidth"
-          stroke-linecap="butt"
-        />
         <!-- 刻度线:所有刻度,按 area 使用对应直线颜色 -->
         <line
           v-for="(pos, idx) in tickPositions"
@@ -43,23 +31,33 @@
           :stroke-width="itemsLineWidth"
           stroke-linecap="butt"
         />
-        <!-- 标签(仅主刻度位置显示),按 area 使用对应文本颜色 -->
-        <template v-if="enableLabels">
-          <text
-            v-for="(lb, idx) in labelList"
-            :key="'label-' + idx"
-            :x="lb.x"
-            :y="lb.y"
-            :text-anchor="lb.textAnchor"
-            :dominant-baseline="lb.dominantBaseline"
-            :fill="lb.fill"
-            :font-size="mainTextSize"
-            :font-family="mainFontFamily"
-          >
-            {{ lb.text }}
-          </text>
-        </template>
+        <!-- 主轴直线:按 area 分段绘制,每段使用对应范围的直线颜色 -->
+        <line
+          v-for="(seg, segIdx) in axisSegments"
+          :key="'axis-' + segIdx"
+          :x1="segIdx === 0 ? seg.x1 - itemsLineWidth / 2 : seg.x1"
+          :y1="seg.y1"
+          :x2="segIdx === axisSegments.length - 1 ? seg.x2 + itemsLineWidth / 2 : seg.x2"
+          :y2="seg.y2"
+          :stroke="seg.stroke"
+          :stroke-width="mainLineWidth"
+          stroke-linecap="butt"
+        />
       </svg>
+      <!-- 标签用 HTML 渲染在 SVG 外,避免被 viewBox 裁剪,全部展示 -->
+      <div
+        v-if="enableLabels && labelList.length"
+        class="absolute inset-0 pointer-events-none overflow-visible"
+      >
+        <span
+          v-for="(lb, idx) in labelList"
+          :key="'label-' + idx"
+          class="absolute whitespace-nowrap"
+          :style="labelSpanStyle(lb)"
+        >
+          {{ lb.text }}
+        </span>
+      </div>
     </div>
   </div>
 </template>
@@ -131,8 +129,19 @@ const itemsStyleConfig = computed(() =>
       s.part?.name === 'items' && (s.part?.state === props.state || s.part?.state === 'default')
   )
 )
+// 从 styles 中读取 indicator 的 other.length(主刻度线长度)
+const indicatorStyleConfig = computed(() =>
+  props.styles?.find(
+    (s: any) =>
+      s.part?.name === 'indicator' && (s.part?.state === props.state || s.part?.state === 'default')
+  )
+)
 const tickLength = computed(() => itemsStyleConfig.value?.other?.length ?? 5)
-const mainTickLength = computed(() => Math.max(tickLength.value * 1.5, tickLength.value + 4))
+const mainTickLength = computed(
+  () =>
+    indicatorStyleConfig.value?.other?.length ??
+    Math.max(tickLength.value * 1.5, tickLength.value + 4)
+)
 
 const mainLineColor = computed(() => styleMap.value?.mainStyle?.line?.color ?? '#212121')
 const mainLineWidth = computed(() => styleMap.value?.mainStyle?.line?.width ?? 2)
@@ -150,22 +159,27 @@ const isTopOrLeft = computed(
   () => props.mode === 'horizontal_top' || props.mode === 'vertical_left'
 )
 
-// 刻度数量、主刻度数量
+// 刻度数量、主刻度间隔(空格数)
 const tickCount = computed(() => Math.max(2, props.tick))
-const mainTickCount = computed(() => Math.max(1, Math.min(props.mainTick, tickCount.value)))
+// 主刻度间隔:表示两个主刻度之间包含的“空格”数量(即小刻度间的间隔数)
+const majorTickGap = computed(() => Math.max(1, Math.min(props.mainTick || 1, tickCount.value - 1)))
 
-// 主刻度在刻度数组中的索引集合
+// 主刻度在刻度数组中的索引集合:始终从 0 开始,按 mainTick 作为“空格数”间隔
+// 若最后剩余的间隔不足 mainTick,则末尾不再补主刻度(“下个大刻度不出来就不展示标签”)
 const majorIndices = computed(() => {
   const set = new Set<number>()
   const n = tickCount.value
-  const m = mainTickCount.value
-  if (m <= 1) {
+  const gap = majorTickGap.value
+  if (n <= 1) {
     set.add(0)
     return set
   }
-  for (let i = 0; i < m; i++) {
-    const idx = Math.round((i * (n - 1)) / (m - 1))
-    set.add(Math.min(idx, n - 1))
+
+  const lastIndex = n - 1
+  const maxStep = Math.floor(lastIndex / gap)
+  for (let step = 0; step <= maxStep; step++) {
+    const idx = step * gap
+    if (idx <= lastIndex) set.add(idx)
   }
   return set
 })
@@ -181,19 +195,24 @@ const tickNormPositions = computed(() => {
 
 // 根据 mode 计算轴线坐标(主轴直线)
 // horizontal_top: 主轴贴在最底部;horizontal_bottom: 主轴贴在最顶部
+// vertical_left: 主轴贴在最右侧;vertical_right: 主轴贴在最左侧
 const axisLine = computed(() => {
   const { width, height } = props
-  const pad = mainTickLength.value + 8
   const halfLine = mainLineWidth.value / 2
   if (isHorizontal.value) {
     const y = isTopOrLeft.value ? height - halfLine : halfLine
     return { x1: 0, y1: y, x2: width, y2: y }
   }
-  const x = isTopOrLeft.value ? pad : width - pad
+  // 垂直模式下:根据模式贴边放置主轴
+  const x =
+    props.mode === 'vertical_left'
+      ? width - halfLine // 主轴靠最右
+      : halfLine // 主轴靠最左(vertical_right)
   return { x1: x, y1: 0, x2: x, y2: height }
 })
 
 // 主轴按 area 分段:根据范围边界拆成多段,每段使用对应 area 的直线颜色
+//  两端需要处理刻度线的宽度
 const axisSegments = computed(() => {
   const { width, height } = props
   const start = props.rangeStart
@@ -251,7 +270,6 @@ const axisSegments = computed(() => {
 // 刻度线端点:与主轴共线;每根刻度按数值取 area 直线颜色
 const tickPositions = computed(() => {
   const { width, height } = props
-  const pad = mainTickLength.value + 8
   const axis = axisLine.value
   const norm = tickNormPositions.value
   const major = majorIndices.value
@@ -274,8 +292,9 @@ const tickPositions = computed(() => {
       })
     })
   } else {
-    const xAxis = isTopOrLeft.value ? pad : width - pad
-    const sign = isTopOrLeft.value ? -1 : 1
+    // 垂直模式下,刻度与主轴共线:vertical_left 主轴在右侧,刻度向左伸展;vertical_right 相反
+    const xAxis = axis.x1
+    const sign = props.mode === 'vertical_left' ? -1 : 1
     norm.forEach((t, idx) => {
       const y = t * height
       const len = major.has(idx) ? mainTickLength.value : tickLength.value
@@ -292,7 +311,7 @@ const tickPositions = computed(() => {
   return list
 })
 
-// 标签文案:有设置内容用设置内容,否则用主刻度数值(根据范围变化),主刻度数为整数
+// 标签文案:有设置内容用设置内容,否则用主刻度对应的数值(根据范围变化)
 const labelTexts = computed(() => {
   const raw = (props.labels || '').trim()
   if (raw) {
@@ -301,24 +320,75 @@ const labelTexts = computed(() => {
       .map((s) => s.trim())
       .filter(Boolean)
   }
-  const m = mainTickCount.value
-  const start = props.rangeStart
-  const end = props.rangeEnd
+  const indices = Array.from(majorIndices.value).sort((a, b) => a - b)
+  if (!indices.length) return []
+  const n = tickCount.value
   const list: string[] = []
-  for (let i = 0; i < m; i++) {
-    const v = m <= 1 ? start : start + ((end - start) * i) / (m - 1)
-    list.push(String(Math.round(v)))
-  }
+  indices.forEach((idx) => {
+    const t = n <= 1 ? 0.5 : idx / (n - 1)
+    const v = valueAt(t)
+    list.push(String(Math.floor(v)))
+  })
   return list
 })
 
+// 标签的 HTML 定位样式:根据不同模式,按主刻度长度 + 5 偏移
+function labelSpanStyle(lb: {
+  x: number
+  y: number
+  textAnchor: string
+  dominantBaseline: string
+  fill: string
+}) {
+  const style: Record<string, string> = {
+    color: lb.fill as string,
+    fontSize: String(mainTextSize.value),
+    fontFamily: String(mainFontFamily.value)
+  }
+
+  const offset = `${mainTickLength.value + 5}px`
+
+  switch (props.mode) {
+    // horizontal_top:不设置 top,bottom 为 主刻度长度 + 5
+    case 'horizontal_top': {
+      style.left = props.width > 0 ? `${(lb.x / props.width) * 100}%` : '0'
+      style.bottom = offset
+      style.transform = 'translateX(-50%)'
+      break
+    }
+    // horizontal_bottom:不设置 bottom,top 为 主刻度长度 + 5
+    case 'horizontal_bottom': {
+      style.left = props.width > 0 ? `${(lb.x / props.width) * 100}%` : '0'
+      style.top = offset
+      style.transform = 'translateX(-50%)'
+      break
+    }
+    // vertical_right:top 按刻度位置动态计算,left 为 主刻度长度 + 5
+    case 'vertical_right': {
+      style.top = props.height > 0 ? `${(lb.y / props.height) * 100}%` : '0'
+      style.left = offset
+      style.transform = 'translateY(-50%)'
+      break
+    }
+    // vertical_left:不设置 left,right 为 主刻度长度 + 5
+    case 'vertical_left': {
+      style.top = props.height > 0 ? `${(lb.y / props.height) * 100}%` : '0'
+      style.right = offset
+      style.transform = 'translateY(-50%)'
+      break
+    }
+  }
+
+  return style
+}
+
 // 标签位置与对齐(仅主刻度位置),按 area 使用对应文本颜色
 const labelList = computed(() => {
   if (!props.enableLabels || labelTexts.value.length === 0) return []
   const { width, height } = props
-  const pad = mainTickLength.value + 8
   const labelGap = 4
-  const m = mainTickCount.value
+  const indices = Array.from(majorIndices.value).sort((a, b) => a - b)
+  const m = indices.length
   const texts = labelTexts.value
   const list: {
     x: number
@@ -336,7 +406,8 @@ const labelList = computed(() => {
       ? yAxis - mainTickLength.value - labelGap
       : yAxis + mainTickLength.value + labelGap
     for (let i = 0; i < m; i++) {
-      const t = m <= 1 ? 0.5 : i / (m - 1)
+      const idx = indices[i]
+      const t = tickCount.value <= 1 ? 0.5 : idx / (tickCount.value - 1)
       const x = t * width
       const value = valueAt(t)
       list.push({
@@ -349,12 +420,14 @@ const labelList = computed(() => {
       })
     }
   } else {
-    const xAxis = isTopOrLeft.value ? pad : width - pad
+    // 垂直模式下,标签位置基于主轴位置再偏移主刻度长度和间距
+    const xAxis = axisLine.value.x1
     const labelX = isTopOrLeft.value
       ? xAxis - mainTickLength.value - labelGap
       : xAxis + mainTickLength.value + labelGap
     for (let i = 0; i < m; i++) {
-      const t = m <= 1 ? 0.5 : i / (m - 1)
+      const idx = indices[i]
+      const t = tickCount.value <= 1 ? 0.5 : idx / (tickCount.value - 1)
       const y = t * height
       const value = valueAt(t)
       list.push({

+ 5 - 5
src/renderer/src/lvgl-widgets/scale/index.tsx

@@ -214,7 +214,7 @@ export default {
         label: '模式',
         field: 'props.mode',
         valueType: 'select',
-        labelWidth: '80px',
+        labelWidth: '60px',
         componentProps: {
           options: [
             { label: 'Horizontal Top', value: 'horizontal_top' },
@@ -228,7 +228,7 @@ export default {
         label: '刻度',
         field: 'props.tick',
         valueType: 'number',
-        labelWidth: '80px',
+        labelWidth: '60px',
         componentProps: {
           min: 2,
           max: 10000
@@ -238,7 +238,7 @@ export default {
         label: '主刻度',
         field: 'props.mainTick',
         valueType: 'number',
-        labelWidth: '80px',
+        labelWidth: '60px',
         componentProps: {
           min: 2,
           max: 10000
@@ -278,13 +278,13 @@ export default {
         label: '启用文本',
         field: 'props.enableLabels',
         valueType: 'switch',
-        labelWidth: '80px'
+        labelWidth: '60px'
       },
       {
         label: '标签',
         field: 'props.labels',
         valueType: 'textarea',
-        labelWidth: '80px',
+        labelWidth: '60px',
         componentProps: {
           placeholder: '标签内容用英文逗号分隔'
         }

+ 3 - 3
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -136,9 +136,9 @@ const getStyle = computed((): CSSProperties => {
 
   let rotate = ''
   // 存在旋转属性
-  if (schema.props?.rotate) {
-    rotate = `rotate(${schema.props.rotate}deg)`
-    other.transformOrigin = `${schema.props.center.x}px ${schema.props.center.y}px`
+  if (schema.props?.rotation) {
+    rotate = `rotate(${schema.props.rotation}deg)`
+    other.transformOrigin = `${schema.props.pivot.x}px ${schema.props.pivot.y}px`
 
     if (other.transform) {
       other.transform += ` ${rotate}`