OperationCard.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. <template>
  2. <view class="operation">
  3. <!-- 右上角设置入口:打开设置弹窗 -->
  4. <view class="operation__setting" @tap.stop="openSetting">
  5. <u-icon name="setting" size="36" color="#909399"></u-icon>
  6. </view>
  7. <view class="operation__header">
  8. <image class="operation__icon" :src="iconSrc" mode="aspectFit" />
  9. <view class="operation__name">{{
  10. dourceData.display_name || dourceData.name || dourceData.code
  11. }}</view>
  12. </view>
  13. <view class="operation__body">
  14. <!-- 三状态:按钮形式(0 停止 1 正向 2 反向)+ 行进进度条 -->
  15. <block v-if="hasPos">
  16. <view class="operation__btns">
  17. <view
  18. class="operation__btn"
  19. :class="btnClass(opt.value)"
  20. v-for="opt in options"
  21. :key="opt.value"
  22. @click="handleClick(opt.value)"
  23. >{{ opt.label }}</view
  24. >
  25. </view>
  26. <!-- 行进进度条:0 / 当前值 / 100 刻度在条上方,当前值 0/100 时与端点刻度重合隐藏 -->
  27. <view class="operation__progress">
  28. <view class="operation__scale">
  29. <view class="operation__num">0</view>
  30. <view
  31. v-if="posValue > 0 && posValue < 100"
  32. class="operation__num operation__num--pos"
  33. :style="{ left: posLeft }"
  34. >{{ posValue }}</view
  35. >
  36. <view class="operation__num operation__num--end">100</view>
  37. </view>
  38. <view class="operation__track">
  39. <view class="operation__fill" :style="{ width: posWidth }"></view>
  40. </view>
  41. </view>
  42. </block>
  43. <!-- 两状态:单个开关(1 开 0 关) -->
  44. <view v-else class="operation__switch-row">
  45. <view
  46. class="operation__switch-label"
  47. :class="{ on: statusValue == 1 }"
  48. >{{ statusValue == 1 ? '开' : '关' }}</view
  49. >
  50. <u-switch
  51. :value="statusValue == 1"
  52. active-color="#0bbc58"
  53. inactive-color="#C3CAD8"
  54. size="36"
  55. @input="handleSwitch"
  56. ></u-switch>
  57. </view>
  58. </view>
  59. <!-- 设置弹窗:参数设置(运行/间隔时间,走 config 接口)+ 行程设置(TargetPos,走 devctl 接口) -->
  60. <u-popup v-model="settingVisible" mode="bottom" border-radius="24" :safe-area-inset-bottom="true">
  61. <view class="setting">
  62. <view class="setting__head">
  63. <view class="setting__title">{{ settingName }}设置</view>
  64. <view class="setting__close" @click="settingVisible = false">
  65. <u-icon name="close" size="32" color="#909399"></u-icon>
  66. </view>
  67. </view>
  68. <view class="setting__section">
  69. <view class="setting__section-title">参数设置</view>
  70. <view class="setting__item">
  71. <view class="setting__label">运行时间</view>
  72. <view class="setting__input-box">
  73. <input
  74. class="setting__input"
  75. type="number"
  76. maxlength="5"
  77. :value="settingForm.runTime"
  78. placeholder="0"
  79. @input="filterNum('runTime', $event)"
  80. />
  81. <view class="setting__unit">秒</view>
  82. </view>
  83. </view>
  84. <view class="setting__item">
  85. <view class="setting__label">间隔时间</view>
  86. <view class="setting__input-box">
  87. <input
  88. class="setting__input"
  89. type="number"
  90. maxlength="5"
  91. :value="settingForm.intervalTime"
  92. placeholder="0"
  93. @input="filterNum('intervalTime', $event)"
  94. />
  95. <view class="setting__unit">秒</view>
  96. </view>
  97. </view>
  98. <view class="setting__acts">
  99. <view class="setting__btn" @click="settingVisible = false">取消</view>
  100. <view class="setting__btn setting__btn--primary" @click="saveSetting('params')">保存</view>
  101. </view>
  102. </view>
  103. <view class="setting__divider"></view>
  104. <view class="setting__section">
  105. <view class="setting__section-title">行程设置</view>
  106. <view class="setting__item">
  107. <view class="setting__label">行程设置</view>
  108. <view class="setting__input-box">
  109. <input
  110. class="setting__input"
  111. type="number"
  112. maxlength="5"
  113. :value="settingForm.stroke"
  114. placeholder="0"
  115. @input="filterNum('stroke', $event)"
  116. />
  117. <view class="setting__unit">度</view>
  118. </view>
  119. </view>
  120. <view class="setting__acts">
  121. <view class="setting__btn" @click="settingVisible = false">取消</view>
  122. <view class="setting__btn setting__btn--primary" @click="saveSetting('stroke')">保存</view>
  123. </view>
  124. </view>
  125. </view>
  126. </u-popup>
  127. </view>
  128. </template>
  129. <script>
  130. const ICONS = {
  131. inner_shade_on: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/inner_shade_on.png',
  132. inner_shade_off: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/inner_shade_off.png',
  133. outer_shade_on: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/outer_shade_on.png',
  134. outer_shade_off: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/outer_shade_off.png',
  135. roof_window_on: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/roof_window_on.png',
  136. roof_window_off: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/roof_window_off.png',
  137. side_window_on: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/side_window_on.png',
  138. side_window_off: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/side_window_off.png',
  139. fan_on: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/fan_on.png',
  140. fan_off: 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/greenHouseIcon/fan_off.png',
  141. };
  142. export default {
  143. name: 'OperationCard',
  144. props: {
  145. dourceData: {
  146. type: Object,
  147. default: () => ({}),
  148. },
  149. },
  150. data() {
  151. return {
  152. settingVisible: false,
  153. // 设置弹窗回显:LimitRun 运行时间 / StepIdle 间隔时间 / TargetPos 行程
  154. settingForm: {
  155. runTime: '',
  156. intervalTime: '',
  157. stroke: '',
  158. },
  159. };
  160. },
  161. computed: {
  162. settingName() {
  163. const d = this.dourceData;
  164. return d.display_name || d.name || d.code || '设备';
  165. },
  166. // 存在 CurrentPos 字段即为三状态(0 停止 1 正向 2 反向),否则两状态(0 停止 1 开启)
  167. // CurrentPos 可能是明文字段或 ChannelParamSetXX:CurrentPos 平铺属性,只用于判定形态
  168. hasPos() {
  169. const d = this.dourceData;
  170. if ('CurrentPos' in d) {
  171. return true;
  172. }
  173. return Object.keys(d).some((k) =>
  174. /^ChannelParamSet\d+:CurrentPos$/.test(k)
  175. );
  176. },
  177. options() {
  178. return [
  179. { label: '正向', value: 1 },
  180. { label: '反向', value: 2 },
  181. { label: '停止', value: 0 },
  182. ];
  183. },
  184. // 通道遥测 key:形如 ChannelParamSet00:Status
  185. statusKey() {
  186. const d = this.dourceData;
  187. const code = d.code || d.sfCode;
  188. if (code && /^ChannelParamSet\d+:Status$/.test(String(code))) {
  189. return String(code);
  190. }
  191. return (
  192. Object.keys(d).find((k) => /^ChannelParamSet\d+:Status$/.test(k)) || ''
  193. );
  194. },
  195. // Status 值存放的属性名:同名属性或 value
  196. statusField() {
  197. const d = this.dourceData;
  198. return this.statusKey && this.statusKey in d ? this.statusKey : 'value';
  199. },
  200. // 高亮始终取 Status 值:三状态 0 停止 1 正向 2 反向,两状态 0 停止 1 开启
  201. statusValue() {
  202. const d = this.dourceData;
  203. const val = this.statusKey ? d[this.statusKey] : d.value;
  204. return val == null ? 0 : Number(val);
  205. },
  206. // 行进位置遥测 key:ChannelParamSetXX:CurrentPos 或明文 CurrentPos,仅三态有
  207. posKey() {
  208. const d = this.dourceData;
  209. if ('CurrentPos' in d) {
  210. return 'CurrentPos';
  211. }
  212. return (
  213. Object.keys(d).find((k) => /^ChannelParamSet\d+:CurrentPos$/.test(k)) ||
  214. ''
  215. );
  216. },
  217. // 当前行进位置(0~100),无效值按 0 处理
  218. posValue() {
  219. const d = this.dourceData;
  220. const num = Number(this.posKey ? d[this.posKey] : 0);
  221. return Number.isFinite(num) ? num : 0;
  222. },
  223. posWidth() {
  224. return `${Math.min(Math.max(this.posValue, 0), 100)}%`;
  225. },
  226. // 当前值数字跟随填充末端,钳制在 8%~92% 防贴边
  227. posLeft() {
  228. return `${Math.min(Math.max(this.posValue, 8), 92)}%`;
  229. },
  230. // 设置弹窗回显/下发 key:ChannelParamSetXX:LimitRun / StepIdle / TargetPos 平铺属性
  231. limitRunKey() {
  232. return (
  233. Object.keys(this.dourceData).find((k) =>
  234. /^ChannelParamSet\d+:LimitRun$/.test(k)
  235. ) || ''
  236. );
  237. },
  238. stepIdleKey() {
  239. return (
  240. Object.keys(this.dourceData).find((k) =>
  241. /^ChannelParamSet\d+:StepIdle$/.test(k)
  242. ) || ''
  243. );
  244. },
  245. targetPosKey() {
  246. return (
  247. Object.keys(this.dourceData).find((k) =>
  248. /^ChannelParamSet\d+:TargetPos$/.test(k)
  249. ) || ''
  250. );
  251. },
  252. // 按通道名匹配图标,随运行状态切换 on/off 变体
  253. iconSrc() {
  254. const d = this.dourceData;
  255. const name = String(d.display_name || d.name || '');
  256. let base = '';
  257. if (name.includes('内遮阳')) {
  258. base = 'inner_shade';
  259. } else if (name.includes('外遮阳')) {
  260. base = 'outer_shade';
  261. } else if (name.includes('顶开窗') || name.includes('顶通风')) {
  262. base = 'roof_window';
  263. } else if (name.includes('侧开窗') || name.includes('侧通风')) {
  264. base = 'side_window';
  265. } else if (name.includes('风机')) {
  266. base = 'fan';
  267. } else {
  268. base = this.hasPos ? 'inner_shade' : 'roof_window';
  269. }
  270. const state = this.statusValue == 0 ? 'off' : 'on';
  271. return ICONS[`${base}_${state}`];
  272. },
  273. },
  274. methods: {
  275. // 打开设置弹窗:回显通道配置(LimitRun 运行时间 / StepIdle 间隔时间 / TargetPos 行程)
  276. openSetting() {
  277. const d = this.dourceData;
  278. const pick = (key) => (key && d[key] != null ? d[key] : 0);
  279. this.settingForm = {
  280. runTime: String(pick(this.limitRunKey)),
  281. intervalTime: String(pick(this.stepIdleKey)),
  282. stroke: String(pick(this.targetPosKey)),
  283. };
  284. this.settingVisible = true;
  285. },
  286. // 运行 / 间隔 / 行程只保留数字,最多 5 位(99999),滤掉其余输入
  287. filterNum(field, e) {
  288. this.settingForm[field] = String(e.detail.value)
  289. .replace(/[^\d]/g, '')
  290. .slice(0, 5);
  291. },
  292. // 按分区保存:抛给父级接接口,弹窗保持打开可继续存另一分区
  293. saveSetting(section) {
  294. const keys = {
  295. limitRunKey: this.limitRunKey,
  296. stepIdleKey: this.stepIdleKey,
  297. targetPosKey: this.targetPosKey,
  298. };
  299. if (section === 'stroke') {
  300. if (!this.targetPosKey) {
  301. uni.showToast({
  302. title: '该设备不支持行程设置',
  303. icon: 'none',
  304. duration: 3000,
  305. });
  306. return;
  307. }
  308. this.$emit('setting-save', {
  309. dourceData: this.dourceData,
  310. section,
  311. data: { stroke: this.settingForm.stroke },
  312. keys,
  313. });
  314. return;
  315. }
  316. if (!this.limitRunKey && !this.stepIdleKey) {
  317. uni.showToast({
  318. title: '该设备不支持参数设置',
  319. icon: 'none',
  320. duration: 3000,
  321. });
  322. return;
  323. }
  324. this.$emit('setting-save', {
  325. dourceData: this.dourceData,
  326. section,
  327. data: {
  328. runTime: this.settingForm.runTime,
  329. intervalTime: this.settingForm.intervalTime,
  330. },
  331. keys,
  332. });
  333. },
  334. // 三状态按钮高亮:正/反向绿色,停止橙色
  335. btnClass(value) {
  336. if (Number(value) !== this.statusValue) {
  337. return '';
  338. }
  339. return Number(value) === 0
  340. ? 'operation__btn--stop'
  341. : 'operation__btn--run';
  342. },
  343. handleClick(value) {
  344. this.$emit('change', {
  345. dourceData: this.dourceData,
  346. value,
  347. code: this.statusKey || this.dourceData.code || '',
  348. field: this.statusField,
  349. });
  350. },
  351. // 两状态开关:切换后 开=1 关=0
  352. handleSwitch(newValue) {
  353. this.handleClick(newValue ? 1 : 0);
  354. },
  355. },
  356. };
  357. </script>
  358. <style scoped lang="scss">
  359. .operation {
  360. position: relative;
  361. // 卡片浅灰底,与头部渐变底色衔接;白色按钮直接落在灰底上
  362. background: #eff2fa;
  363. border-radius: 16rpx;
  364. margin-bottom: 24rpx;
  365. overflow: hidden;
  366. // 右上角设置按钮(padding 扩大点击热区)
  367. &__setting {
  368. position: absolute;
  369. top: 8rpx;
  370. right: 12rpx;
  371. z-index: 2;
  372. padding: 10rpx;
  373. }
  374. &__header {
  375. display: flex;
  376. align-items: center;
  377. padding: 20rpx 72rpx 20rpx 24rpx;
  378. background: linear-gradient(180deg, #eff4ff 20.24%, #eff2fa 100%);
  379. }
  380. &__icon {
  381. flex-shrink: 0;
  382. width: 44rpx;
  383. height: 44rpx;
  384. margin-right: 12rpx;
  385. }
  386. &__name {
  387. color: #333333;
  388. font-family: 'Source Han Sans CN VF';
  389. font-size: 28rpx;
  390. font-weight: 500;
  391. overflow: hidden;
  392. white-space: nowrap;
  393. text-overflow: ellipsis;
  394. }
  395. &__body {
  396. display: flex;
  397. flex-direction: column;
  398. align-items: stretch;
  399. padding: 20rpx 24rpx 24rpx;
  400. }
  401. &__btns {
  402. display: flex;
  403. align-items: center;
  404. }
  405. &__btn {
  406. flex: 1;
  407. height: 72rpx;
  408. display: flex;
  409. align-items: center;
  410. justify-content: center;
  411. border-radius: 12rpx;
  412. background: #ffffff;
  413. border: 2rpx solid #eef0f5;
  414. box-sizing: border-box;
  415. color: #999999;
  416. font-family: 'Source Han Sans CN VF';
  417. font-size: 26rpx;
  418. font-weight: 400;
  419. margin-right: 16rpx;
  420. &:last-child {
  421. margin-right: 0;
  422. }
  423. &--run {
  424. background: #0bbc58;
  425. border-color: #0bbc58;
  426. color: #ffffff;
  427. font-weight: 500;
  428. }
  429. &--stop {
  430. background: #ffa200;
  431. border-color: #ffa200;
  432. color: #ffffff;
  433. font-weight: 500;
  434. }
  435. }
  436. // 行进进度条:绿色填充 + 0 / 当前值 / 100 刻度(刻度在条上方)
  437. &__progress {
  438. margin-top: 20rpx;
  439. }
  440. &__scale {
  441. position: relative;
  442. height: 30rpx;
  443. margin-bottom: 4rpx;
  444. color: #909399;
  445. font-family: 'Source Han Sans CN VF';
  446. font-size: 22rpx;
  447. line-height: 30rpx;
  448. }
  449. &__num {
  450. &--pos {
  451. position: absolute;
  452. top: 0;
  453. transform: translateX(-50%);
  454. color: #0db02b;
  455. font-weight: 600;
  456. }
  457. &--end {
  458. position: absolute;
  459. top: 0;
  460. right: 0;
  461. }
  462. }
  463. &__track {
  464. height: 12rpx;
  465. border-radius: 6rpx;
  466. background: #c3cad8;
  467. overflow: hidden;
  468. }
  469. &__fill {
  470. display: block;
  471. height: 100%;
  472. background: #0bbc58;
  473. }
  474. &__switch-row {
  475. display: flex;
  476. align-items: center;
  477. justify-content: space-between;
  478. }
  479. &__switch-label {
  480. color: #999999;
  481. font-family: 'Source Han Sans CN VF';
  482. font-size: 26rpx;
  483. font-weight: 400;
  484. &.on {
  485. color: #333333;
  486. }
  487. }
  488. }
  489. // 设置弹窗(底部弹出)
  490. .setting {
  491. padding: 32rpx;
  492. &__head {
  493. display: flex;
  494. align-items: center;
  495. justify-content: space-between;
  496. margin-bottom: 32rpx;
  497. }
  498. &__title {
  499. color: #042118;
  500. font-family: 'Source Han Sans CN VF';
  501. font-size: 32rpx;
  502. font-weight: 700;
  503. }
  504. &__close {
  505. padding: 8rpx;
  506. }
  507. &__section-title {
  508. color: #042118;
  509. font-family: 'Source Han Sans CN VF';
  510. font-size: 28rpx;
  511. font-weight: 600;
  512. margin-bottom: 24rpx;
  513. }
  514. &__item {
  515. display: flex;
  516. align-items: center;
  517. margin-bottom: 24rpx;
  518. }
  519. &__label {
  520. flex-shrink: 0;
  521. width: 150rpx;
  522. color: #666666;
  523. font-family: 'Source Han Sans CN VF';
  524. font-size: 26rpx;
  525. }
  526. &__input-box {
  527. flex: 1;
  528. display: flex;
  529. align-items: center;
  530. height: 72rpx;
  531. padding: 0 20rpx;
  532. background: #eff2fa;
  533. border-radius: 12rpx;
  534. }
  535. &__input {
  536. flex: 1;
  537. height: 100%;
  538. color: #333333;
  539. font-size: 26rpx;
  540. }
  541. &__unit {
  542. margin-left: 12rpx;
  543. color: #999999;
  544. font-size: 26rpx;
  545. }
  546. &__acts {
  547. display: flex;
  548. align-items: center;
  549. justify-content: flex-end;
  550. }
  551. &__btn {
  552. min-width: 140rpx;
  553. height: 64rpx;
  554. display: flex;
  555. align-items: center;
  556. justify-content: center;
  557. padding: 0 28rpx;
  558. background: #ffffff;
  559. border: 2rpx solid #e3e6ee;
  560. border-radius: 12rpx;
  561. box-sizing: border-box;
  562. color: #666666;
  563. font-family: 'Source Han Sans CN VF';
  564. font-size: 26rpx;
  565. margin-left: 20rpx;
  566. &--primary {
  567. background: #0bbc58;
  568. border-color: #0bbc58;
  569. color: #ffffff;
  570. font-weight: 500;
  571. }
  572. }
  573. &__divider {
  574. height: 2rpx;
  575. background: #eef0f5;
  576. margin: 32rpx 0;
  577. }
  578. }
  579. </style>