This commit is contained in:
houjunxiang
2025-10-09 18:19:55 +08:00
parent f2ffc65094
commit 386f1e7466
1553 changed files with 284685 additions and 32820 deletions

View File

@@ -0,0 +1,196 @@
# 1.4.2
新增
1. 新增`filterValue`属性,支持通过此关键词来搜索并筛选树结构的内容
# 1.4.1
修复
1. 修复单选 onlyRadioLeaf 时末级节点无法选中的 bug
# 1.4.0
版本调整
建议更新,但需要注意,异步数据的时候,后台需返回 leaf 字段来判断是否末项数据
1. **调整数据项格式,新增 `leaf` 字段,来判断是否为末节点**
2. **调整数据项格式,新增 `sort` 字段,来排序节点位置**
3. **注意:异步加载数据,当为末项的时候,需要服务端数据返回 `leaf` 字段**
4. 新增 `alwaysFirstLoad` ,即异步数据总会在第一次展开节点时,拉取一次后台数据,来比对是否一致
5. 拆分 `field` 属性,**注意: 1.5.0 版本后将移除 `field` 属性**
6. 新增 `labelField``field.label`,指定节点对象中某个属性为**标签**字段,默认`label`
7. 新增 `valueField``field.key`,指定节点对象中某个属性为**值**字段,默认`value`
8. 新增 `childrenField``field.children`,指定节点对象中某个属性为**子树节点**字段,默认`children`
9. 新增 `disabledField``field.disabled`,指定节点对象中某个属性为**禁用**字段,默认`disabled`
10. 新增 `appendField``field.append`,指定节点对象中某个属性为**副标签**字段,默认`append`
11. 新增 `leafField``field.label`,指定节点对象中某个属性为**末级节点**字段,默认`leaf`
12. 新增 `sortField``field.label`,指定节点对象中某个属性为**排序**字段,默认`sort`
13. 新增 `isLeafFn` ,用来自定义控制数据项的末项
14. 更多的项目示例
15. 支持单选取消选中
16. 修复节点展开时可能存在的 bug
17. 修复节点选择可能存在的 bug
18. 调整为子节点默认继承父节点禁用属性
19. `setExpandedKeys` 添加参数一为 `all` 即可支持一键展开/收起全部节点
20. 其它更多优化
# 1.3.4
优化
1. 优化图标字体命名
# 1.3.3
优化
1. 新增方法调用
> - 新增`getUncheckedKeys`,返回未选的 key
> - 新增`getUncheckedNodes`,返回未选的节点
> - 新增`getUnexpandedKeys`,返回未展开的 key
> - 新增`getUnexpandedNodes`,返回未展开的节点
2. 优化示例项目
# 1.3.2
修复
1. 修复在 APP 真机环境中的报错
# 1.3.1
修复
1. 修复方法`setExpandedKeys`没联动展开上级父子节点
# 1.3.0
优化
1. `field`新增字段 `append` 用于在标签后面显示小提示
2. 新增支持点击标签也能选中节点
3. 方法`setExpandedKeys`支持加载动态数据
4. 修复父节点禁用,则不能展开及图标展开显示
5. 修复动态加载数据时,末级节点的 `children``null` 时仍显示展开图标
# 1.2.6
新增
1. 新增支持主题换色
2. 支持单选的`onlyRadioLeaf``true`时可点父节点展开/收起
3. 优化`expandChecked`调整为不展开无子节点的节点
# 1.2.5
新增
1. 新增 `expandChecked`,控制选择时是否展开当前已选的所有下级节点
# 1.2.4
修复
1. 修复动态数据展开状态异常问题
# 1.2.3
新增
1. 新增 `checkedDisabled`,是否渲染禁用值
2. 新增 `packDisabledkey`,是否返回已禁用并选中的 key
3. 修复选择父级时,子级已禁用但仍被选中的问题
# 1.2.2
优化
1. 调整动态数据载入处理方式
2. 修复节点数据因动态数据引起的状态异常
3. 修复初始节点数据默认选中
# 1.2.1
修复
1. 修复切换`选中状态`被重复选中问题
2. 修复动态数据引起的重复选择问题
# 1.2.0
新增
1. 新增方法调用
> - 新增`setCheckedKeys`,方法设置指定 key 的节点选中状态
> - 新增`setExpandedKeys`,方法设置指定 key 的节点展开状态
2. 修复小程序重复插槽一直刷报错问题
3. 优化展开时,会展开子级所以下级节点
# 1.1.1
新增
1. 新增`data``disabled`,支持节点禁用状态
2. 新增`field``disabled`,可自定`disabled`字段值
# 1.1.0
新增
1. 新增`loadMode``loadApi`,支持展开时加载异步数据
2. 新增方法调用
> - 新增`getCheckedKeys`,方法返回已选的 key
> - 新增`getHalfCheckedKeys`,方法返回半选的 key
> - 新增`getExpandedKeys`,方法返回已展开的 key
> - 新增`getCheckedNodes`,方法返回已选的节点
> - 新增`getHalfCheckedNodes`,方法返回半选的节点
> - 新增`getExpandedNodes`,方法返回已展开的节点
3. 对代码进行重构,更易于后期拓展
4. 此次更新后,页面多个的 DaTee 组件间的数据不再关联
# 1.0.6
新增
1. 新增`checkStrictly`,多选模式下选中时是否父子不关联
# 1.0.5
修复
1. 修复多选时已选数据重复问题
# 1.0.4
修复
1. 修复 `change` 事件回调数据的问题
# 1.0.3
优化
1. 优化文档及示例说明
# 1.0.2
新增
1. 新增 `onlyRadioLeaf` ,单选时只允许选中末级
2. 优化默认展开及默认选择的展开问题
# 1.0.1
新增
1. 支持展开/收起回调事件`@expand`
# 1.0.0
初始版本 1.0.0,基于 Vue3 进行开发,支持单选、多选,兼容各大平台
1. 支持单选
2. 支持多选

1181
components/da-tree/index.vue Normal file

File diff suppressed because it is too large Load Diff

197
components/da-tree/props.ts Normal file
View File

@@ -0,0 +1,197 @@
export default {
/**
* 树的数据
*/
data: {
type: Array,
default: () => []
},
/**
* 主题色
*/
themeColor: {
type: String,
default: '#007aff'
},
/**
* 是否开启多选,默认单选
*/
showCheckbox: {
type: Boolean,
default: false
},
/**
* 默认选中的节点注意单选时为单个key多选时为key的数组
*/
defaultCheckedKeys: {
type: [Array, String, Number],
default: null
},
/**
* 是否默认展开全部
*/
defaultExpandAll: {
type: Boolean,
default: false
},
/**
* 默认展开的节点
*/
defaultExpandedKeys: {
type: Array,
default: null
},
/**
* 筛选关键词
*/
filterValue: {
type: String,
default: ''
},
/**
* 是否自动展开到选中的节点,默认不展开
*/
expandChecked: {
type: Boolean,
default: false
},
/**
* (旧)字段对应内容,默认为 {label: 'label',key: 'key', children: 'children', disabled: 'disabled', append: 'append'}
* 注意1.5.0版本后不再兼容
*/
field: {
type: Object,
default: null
},
/**
* 标签字段(新,拆分了)
*/
labelField: {
type: String,
default: 'label'
},
/**
* 值字段(新,拆分了)
*/
valueField: {
type: String,
default: 'value'
},
/**
* 下级字段(新,拆分了)
*/
childrenField: {
type: String,
default: 'children'
},
/**
* 禁用字段(新,拆分了)
*/
disabledField: {
type: String,
default: 'disabled'
},
/**
* 末级节点字段(新,拆分了)
*/
leafField: {
type: String,
default: 'leaf'
},
/**
* 副标签字段(新,拆分了)
*/
appendField: {
type: String,
default: 'append'
},
/**
* 排序字段(新,拆分了)
*/
sortField: {
type: String,
default: 'sort'
},
/**
* Api数据返回后的结果路径支持嵌套如`data.list`
*/
resultField: {
type: String,
default: ''
},
isLeafFn: {
type: Function,
default: null
},
/**
* 是否显示单选图标,默认显示
*/
showRadioIcon: {
type: Boolean,
default: true
},
/**
* 单选时只允许选中末级,默认可随意选中
*/
onlyRadioLeaf: {
type: Boolean,
default: false
},
/**
* 多选时,是否执行父子不关联的任意勾选,默认父子关联
*/
checkStrictly: {
type: Boolean,
default: false
},
/**
* 为 true 时,空的 children 数组会显示展开图标
*/
loadMode: {
type: Boolean,
default: false
},
/**
* 异步加载接口
*/
loadApi: {
type: Function,
default: null
},
/**
* 是否总在首次的时候加载一下内容,来比对是否一致
*/
alwaysFirstLoad: {
type: Boolean,
default: false
},
/**
* 是否渲染(操作)禁用值
*/
checkedDisabled: {
type: Boolean,
default: false
},
/**
* 是否返回已禁用的但已选中的key
*/
packDisabledkey: {
type: Boolean,
default: true
},
/**
* 选择框的位置,可选 left/right
*/
checkboxPlacement: {
type: String,
default: 'left'
},
/**
* 子项缩进距离默认40单位rpx
*/
indent: {
type: Number,
default: 20
}
}

View File

@@ -0,0 +1,310 @@
# da-tree
一个基于 Vue3 的 tree(树)组件,同时支持主题换色,可能是最适合你的 tree(树)组件
组件一直在更新,遇到问题可在下方讨论。
`同时更新 Vue2 版本,在此查看 ===>` **[Vue2 版](https://ext.dcloud.net.cn/plugin?id=12692)**
### 关于使用
可在右侧的`使用 HBuilderX 导入插件``下载示例项目ZIP`,方便快速上手。
可通过下方的示例及文档说明,进一步了解使用组件相关细节参数。
插件地址https://ext.dcloud.net.cn/plugin?id=12384
### 组件示例
```jsx
<template>
<view>多选</view>
<view><button @click="doCheckedTree(['2'],true)">全选</button></view>
<view><button @click="doCheckedTree(['2'],false)">取消全选</button></view>
<view><button @click="doCheckedTree(['211','222'],true)">选中指定节点</button></view>
<view><button @click="doCheckedTree(['211','222'],false)">取消选中指定节点</button></view>
<view><button @click="doExpandTree('all',true)">展开全部节点</button></view>
<view><button @click="doExpandTree('all',false)">收起全部节点</button></view>
<view><button @click="doExpandTree(['22','23'],true)">展开节点</button></view>
<view><button @click="doExpandTree(['22','23'],false)">收起节点</button></view>
<DaTree
ref="DaTreeRef"
:data="roomTreeData"
labelField="name"
valueField="id"
defaultExpandAll
showCheckbox
:defaultCheckedKeys="defaultCheckedKeysValue"
@change="handleTreeChange"
@expand="handleExpandChange"></DaTree>
<view>单选</view>
<DaTree
:data="roomTreeData"
labelField="name"
valueField="id"
defaultExpandAll
:defaultCheckedKeys="defaultCheckedKeysValue2"
@change="handleTreeChange"
@expand="handleExpandChange"></DaTree>
<view>默认展开指定节点</view>
<DaTree
:data="roomTreeData"
labelField="name"
valueField="id"
showCheckbox
:defaultExpandedKeys="defaultExpandKeysValue3"
@change="handleTreeChange"
@expand="handleExpandChange"></DaTree>
<view>异步加载数据</view>
<DaTree
:data="roomTreeData"
labelField="name"
valueField="id"
showCheckbox
loadMode
:loadApi="GetApiData"
defaultExpandAll
@change="handleTreeChange"
@expand="handleExpandChange"></DaTree>
</template>
```
```js
import { defineComponent, ref } from 'vue'
/**
* 模拟创建一个接口数据
*/
function GetApiData(currentNode) {
const { key } = currentNode
return new Promise((resolve) => {
setTimeout(() => {
// 模拟返回空数据
if (key.indexOf('-') > -1) {
return resolve(null)
// return resolve([])
}
return resolve([
{
id: `${key}-1`,
name: `行政部X${key}-1`,
},
{
id: `${key}-2`,
name: `财务部X${key}-2`,
append: '定义了末项数据',
leaf: true,
},
{
id: `${key}-3`,
name: `资源部X${key}-3`,
},
{
id: `${key}-4`,
name: `资源部X${key}-3`,
append: '被禁用,无展开图标',
disabled: true,
},
])
}, 2000)
})
}
import DaTree from '@/components/da-tree/index.vue'
export default defineComponent({
components: { DaTree },
setup() {
const DaTreeRef = ref()
// key的类型必须对应树数据key的类型
const defaultCheckedKeysValue = ref(['211', '222'])
const defaultCheckedKeysValue2 = ref('222')
const defaultExpandKeysValue3 = ref(['212', '231'])
const roomTreeData = ref([
{
id: '2',
name: '行政中心',
children: [
{
id: '21',
name: '行政部',
children: [
{
id: '211',
name: '行政一部',
children: null,
},
{
id: '212',
name: '行政二部',
children: [],
disabled: true,
},
],
},
{
id: '22',
name: '财务部',
children: [
{
id: '221',
name: '财务一部',
children: [],
disabled: true,
},
{
id: '222',
name: '财务二部',
children: [],
},
],
},
{
id: '23',
name: '人力资源部',
children: [
{
id: '231',
name: '人力一部',
children: [],
},
{
id: '232',
name: '人力二部',
append: '更多示例,请下载示例项目查看',
},
],
},
],
},
])
function doExpandTree(keys, expand) {
DaTreeRef.value?.setExpandedKeys(keys, expand)
const gek = DaTreeRef.value?.getExpandedKeys()
console.log('当前已展开的KEY ==>', gek)
}
function doCheckedTree(keys, checked) {
DaTreeRef.value?.setCheckedKeys(keys, checked)
const gek = DaTreeRef.value?.getCheckedKeys()
console.log('当前已选中的KEY ==>', gek)
}
function handleTreeChange(allSelectedKeys, currentItem) {
console.log('handleTreeChange ==>', allSelectedKeys, currentItem)
}
function handleExpandChange(expand, currentItem) {
console.log('handleExpandChange ==>', expand, currentItem)
}
return {
DaTreeRef,
roomTreeData,
defaultCheckedKeysValue,
defaultCheckedKeysValue2,
defaultExpandKeysValue3,
handleTreeChange,
handleExpandChange,
GetApiData,
doExpandTree,
doCheckedTree,
}
},
})
```
** 更多示例请下载/导入示例项目 ZIP 查看 **
### 组件参数
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :------------------ | :------------------------------ | :--------- | :--- | :--------------------------------------------------------------------------- |
| data | `Array` | - | 是 | 树的数据 |
| themeColor | `String` | `#007aff` | 否 | 主题色,十六进制 |
| defaultCheckedKeys | `Array` \| `Number` \| `String` | - | 否 | 默认选中的节点,单选为单个 key多选为 key 的数组 |
| showCheckbox | `Boolean` | `false` | 否 | 是否开启多选,默认单选 |
| checkStrictly | `Boolean` | `false` | 否 | 多选时,是否执行父子不关联的任意勾选,默认父子关联 |
| showRadioIcon | `Boolean` | `true` | 否 | 是否显示单选图标,默认显示 |
| onlyRadioLeaf | `Boolean` | `true` | 否 | 单选时只允许选中末级,默认可随意选中 |
| defaultExpandAll | `Boolean` | `false` | 否 | 是否默认展开全部 |
| defaultExpandedKeys | `Array` | - | 否 | 默认展开的节点 |
| indent | `Number` | `40` | 否 | 子项缩进距离,单位 rpx |
| checkboxPlacement | `String` | `left` | 否 | 选择框的位置,可选 left/right |
| loadMode | `Boolean` | `false` | 否 | 为 true 时,空的 children 数组会显示展开图标 |
| loadApi | `Function` | - | 否 | 选择框的位置,可选 left/right |
| checkedDisabled | `Boolean` | `false` | 否 | 是否渲染禁用值,默认不渲染 |
| packDisabledkey | `Boolean` | `true` | 否 | 是否返回已禁用的但已选中的 key默认返回禁用已选值 |
| expandChecked | `Boolean` | `false` | 否 | 是否自动展开到选中的节点,默认不展开 |
| alwaysFirstLoad | `Boolean` | `false` | 否 | 是否总在首次的时候加载一下内容,默认不加载,否则只有展开末级节点才会加载数据 |
| isLeafFn | `Function` | - | 否 | 自定义函数返回来控制数据项的末项 |
| field | `Object` | - | 否 | 字段对应内容,格式参考下方(1.5.0 后移除,请用单独的字段匹配) |
| labelField | `String` | `label` | 否 | 指定节点对象中某个属性为标签字段,默认`label` |
| valueField | `String` | `value` | 否 | 指定节点对象中某个属性为值字段,默认`value` |
| childrenField | `String` | `children` | 否 | 指定节点对象中某个属性为子树节点字段,默认`children` |
| disabledField | `String` | `disabled` | 否 | 指定节点对象中某个属性为禁用字段,默认`disabled` |
| appendField | `String` | `append` | 否 | 指定节点对象中某个属性为副标签字段,默认`append` |
| leafField | `String` | `leaf` | 否 | 指定节点对象中某个属性为末级节点字段,默认`leaf` |
| sortField | `String` | `sort` | 否 | 指定节点对象中某个属性为排序字段,默认`sort` |
| filterValue | `String` | - | 否 | 搜索筛选的关键词,通过输入关键词筛选内容 |
**field 格式(1.5.0 后移除,请用单独的字段匹配)**
```js
{
label: 'label',
key: 'key',
children: 'children',
disabled: 'disabled',
append: 'append'
}
```
### 组件事件
| 事件名称 | 回调参数 | 说明 |
| :------- | :-------------------------------------- | :-------------- |
| change | `(allCheckedKeys, currentItem) => void` | 选中时回调 |
| expand | `(expandState, currentItem) => void` | 展开/收起时回调 |
### 组件方法
| 方法名称 | 参数 | 说明 |
| :------------------ | :--------------- | :------------------------------------------------------------------------------------------------ |
| setCheckedKeys | `(keys,checked)` | 设置指定 key 的节点选中/取消选中的状态。注: keys 单选时为 key多选时为 key 的数组 |
| setExpandedKeys | `(keys,expand)` | 设置指定 key 的节点展开/收起的状态,当 keys 为 all 时即代表展开/收起全部。注keys 为数组或 `all` |
| getCheckedKeys | - | 返回已选的 key |
| getHalfCheckedKeys | - | 返回半选的 key |
| getUncheckedKeys | - | 返回未选的 key |
| getCheckedNodes | - | 返回已选的节点 |
| getUncheckedNodes | - | 返回未选的节点 |
| getHalfCheckedNodes | - | 返回半选的节点 |
| getExpandedKeys | - | 返回已展开的 key |
| getUnexpandedKeys | - | 返回未展开的 key |
| getExpandedNodes | - | 返回已展开的节点 |
| getUnexpandedNodes | - | 返回未展开的节点 |
### 组件版本
v1.4.2
### 差异化
已通过测试
> - H5 页面
> - 微信小程序
> - 支付宝、钉钉小程序
> - 字节跳动、抖音、今日头条小程序
> - 百度小程序
> - 飞书小程序
> - QQ 小程序
> - 京东小程序
未测试
> - 快手小程序由于非企业用户暂无演示
> - 快应用、360 小程序因 Vue3 支持的原因暂无演示
### 开发组
[@CRLANG](https://crlang.com)

150
components/da-tree/utils.ts Normal file
View File

@@ -0,0 +1,150 @@
/** 未选 */
export const unCheckedStatus = 0
/** 半选 */
export const halfCheckedStatus = 1
/** 选中 */
export const isCheckedStatus = 2
/**
* 深拷贝内容
* @param originData 拷贝对象
* @author crlang(https://crlang.com)
*/
export function deepClone(originData) {
const type = Object.prototype.toString.call(originData)
let data
if (type === '[object Array]') {
data = []
for (let i = 0; i < originData.length; i++) {
data.push(deepClone(originData[i]))
}
} else if (type === '[object Object]') {
data = {}
for (const prop in originData) {
// eslint-disable-next-line no-prototype-builtins
if (originData.hasOwnProperty(prop)) { // 非继承属性
data[prop] = deepClone(originData[prop])
}
}
} else {
data = originData
}
return data
}
/**
* 获取所有指定的节点
* @param type
* @param value
* @author crlang(https://crlang.com)
*/
export function getAllNodes(list, type, value, packDisabledkey = true) {
if (!list || list.length === 0) {
return []
}
const res = []
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item[type] === value) {
if ((packDisabledkey && item.disabled) || !item.disabled) {
res.push(item)
}
}
}
return res
}
/**
* 获取所有指定的key值
* @param type
* @param value
* @author crlang(https://crlang.com)
*/
export function getAllNodeKeys(list, type, value, packDisabledkey = true) {
if (!list || list.length === 0) {
return null
}
const res = []
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item[type] === value) {
if ((packDisabledkey && item.disabled) || !item.disabled) {
res.push(item.key)
}
}
}
return res.length ? res : null
}
/**
* 错误输出
*
* @param msg
*/
export function logError(msg, ...args) {
console.error(`DaTree: ${msg}`, ...args)
}
const toString = Object.prototype.toString
export function is(val, type) {
return toString.call(val) === `[object ${type}]`
}
/**
* 是否对象(Object)
* @param val
*/
export function isObject(val) {
return val !== null && is(val, 'Object')
}
/**
* 是否数字(Number)
* @param val
*/
export function isNumber(val) {
return is(val, 'Number')
}
/**
* 是否字符串(String)
* @param val
*/
export function isString(val) {
return is(val, 'String')
}
/**
* 是否函数方法(Function)
* @param val
*/
export function isFunction(val) {
return typeof val === 'function'
}
/**
* 是否布尔(Boolean)
* @param val
*/
export function isBoolean(val) {
return is(val, 'Boolean')
}
/**
* 是否数组(Array)
* @param val
*/
export function isArray(val) {
return val && Array.isArray(val)
}

View File

@@ -0,0 +1,80 @@
//my-tabbar文件
<template>
<view>
<u-tabbar
:value="currentTab"
:fixed="true"
:border="false"
activeColor="#0055a2"
:placeholder="false"
@change="changeTabIndex"
>
<u-tabbar-item
v-for="item in switchTabs"
:key="item.name"
:text="item.text"
:icon="item.iconName"
></u-tabbar-item>
</u-tabbar>
</view>
</template>
<script setup>
import { computed, reactive } from 'vue'
let props = defineProps({
currentTab: {
type: Number,
default: 0
}
})
const switchTabs = reactive([
{
pagePath: '/pages/lims/index/index',
iconName: 'home',
text: '首页',
name: 'home'
},
{
pagePath: '/pages/me/index',
iconName: 'account',
text: '我的',
name: 'account'
}
])
// const switchTabs = computed(() => {
// return [
// {
// pagePath: '/pages/lims/index/index',
// iconName: 'home',
// text: '首页',
// name: 'home'
// },
// {
// pagePath: '/pages/me/index',
// iconName: 'account',
// text: '我的',
// name: 'account'
// }
// ]
// })
function changeTabIndex(e) {
let pagePath = switchTabs[e].pagePath
uni.switchTab({
url: pagePath
})
}
</script>
<style lang="scss" scoped>
::v-deep .u-tabbar__content {
background-color: #f2f2f2;
padding: 10rpx 0;
.u-icon__icon {
font-size: 54rpx !important;
}
.u-tabbar-item__text {
font-size: 38rpx;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="content">
<view class="x-c">
<image style="width: 80px; height: 80px" :src="`/static/images/menus/${icon}.png`"></image>
</view>
<view class="pt50 pb50 fs30">
<span>{{ title }}</span>
</view>
<view class="x-c">
<u-icon size="150" color="#0055A2" name="scan"></u-icon>
</view>
<!-- #ifdef H5 -->
<up-search
shape="square"
placeholder="请输入设备id"
actionText="查询"
:clearabled="false"
:showAction="true"
@custom="handleInputSearch"
></up-search>
<!-- #endif -->
</view>
</template>
<script setup>
let props = defineProps({
title: {
type: String,
default: '请扫描设备'
},
icon: {
type: String,
default: 'useRecord'
}
})
const emits = defineEmits(['deviceId'])
const customIconsMap = new Map([
['useRecord', '&#xe66e;'],
['dailyCheck', '&#xe614;'],
['maintain', '&#xe60e;'],
['periodCheck', '&#xe676;'],
['calibration', '&#xe83b;'],
['accept', '&#xe6de;'],
['transfer', '&#xe603;']
])
function iconMap(key) {
return customIconsMap.get(key)
}
function handleInputSearch(e) {
emits('deviceId', e)
}
</script>
<style lang="scss" scoped>
@font-face {
font-family: 'CustomFont';
src: url('@/static/iconfont/iconfont.ttf');
}
.content {
padding-top: 100px;
text-align: center;
background-color: #fff;
box-sizing: border-box;
height: 100%;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<view>
<up-upload
v-if="isUpdate"
:file-list="fileList"
:accept="accept"
multiple
:size-type="['original', 'compressed']"
:max-count="maxCount"
:width="width"
:height="height"
:preview-full-image="true"
:disabled="disabled"
@delete="deletePic"
@after-read="afterRead"
>
</up-upload>
<u-album v-else multiple-size="85" single-size="85" :urls="imgs" row-count="4" />
</view>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { getBaseUrl, getImgBaseUrl } from '@/defaultBaseUrl'
const props = defineProps({
modelValue: { type: [Array, String], default: '' },
isUpdate: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
accept: { type: String, default: 'image' },
width: { type: String, default: '80px' },
height: { type: String, default: '80px' },
maxCount: { type: Number, default: 20 },
returnAsString: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue'])
const fileList = ref([])
const imgs = ref([])
const uploadConfig = reactive({
baseURL: getBaseUrl(),
imgBaseURL: getImgBaseUrl(),
header: { 'X-Access-Token': uni.getStorageSync('token') }
})
// 同步外部值到组件
watch(
() => props.modelValue,
newVal => {
if (!newVal) return (fileList.value = [])
const urls = Array.isArray(newVal) ? newVal : newVal.split(',')
imgs.value = urls
fileList.value = urls.map(url => ({
url: uploadConfig.imgBaseURL + url,
status: 'success',
message: ''
}))
},
{ immediate: true }
)
const deletePic = event => {
fileList.value.splice(event.index, 1)
updateParent(fileList.value)
}
const afterRead = async event => {
const files = [].concat(event.file)
const initialLength = fileList.value.length
// 添加上传状态
fileList.value.push(
...files.map(file => ({
...file,
status: 'uploading',
message: '上传中'
}))
)
try {
for (let i = 0; i < files.length; i++) {
const result = await uploadFile(files[i])
const index = initialLength + i
fileList.value[index] = {
...files[i],
status: 'success',
url: uploadConfig.imgBaseURL + result.message
}
}
} catch (error) {
console.error('Upload failed:', error)
// 回滚失败的上传
fileList.value.splice(initialLength, files.length)
} finally {
updateParent(fileList.value)
}
}
const uploadFile = file => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${uploadConfig.baseURL}/sys/common/upload`,
filePath: file.url,
name: 'file',
header: uploadConfig.header,
formData: { biz: 'lims-device' },
success: res => {
if (res.statusCode === 200) {
resolve(JSON.parse(res.data))
} else {
reject(new Error(res.data))
}
},
fail: reject
})
})
}
const formatValue = fileList => {
// 去掉 imgBaseURL 后的部分
const baseUrlLength = uploadConfig.imgBaseURL.length
return props.returnAsString
? fileList.map(item => item.url.slice(baseUrlLength)).join(',')
: fileList.map(item => item.url.slice(baseUrlLength))
}
const updateParent = fileList => {
emit('update:modelValue', formatValue(fileList))
}
</script>
<style lang="scss" scoped>
:deep(.u-upload__wrap__preview) {
margin: 0;
}
</style>

View File

@@ -0,0 +1,557 @@
<template>
<view>
<view v-if="visible" class="s-verify-mask" @touchmove.stop.prevent>
<view class="s-verify-wrapper" @tap.stop>
<view class="s-verify-header">
<text class="s-verify-title">安全验证</text>
<view class="s-verify-close" @tap="handleClose">×</view>
</view>
<view class="s-verify-body">
<view class="s-verify-image" :style="imageStyle">
<image v-if="backgroundImage" class="s-verify-image-bg" :src="backgroundImage" mode="aspectFill" />
<image
v-if="showBlock"
class="s-verify-image-block"
:style="blockStyle"
:src="blockImage"
mode="widthFix"
/>
<view class="s-verify-refresh" @tap="refreshCaptcha">
<text></text>
</view>
<view v-if="loading" class="s-verify-loading">加载中...</view>
</view>
<view v-if="tipMessage" :class="['s-verify-tip', tipClass]">
{{ tipMessage }}
</view>
<view
class="s-verify-slider"
ref="sliderRef"
@touchstart.stop.prevent="onDragStart"
@touchmove.stop.prevent="onDragMove"
@touchend.stop.prevent="onDragEnd"
@mousedown.stop="onMouseDown"
>
<view class="s-verify-slider-track" />
<view class="s-verify-slider-fill" :style="sliderFillStyle" />
<view
class="s-verify-slider-handle"
:class="{
's-verify-slider-success': success,
's-verify-slider-fail': failAnimate
}"
:style="sliderHandleStyle"
>
<text class="s-verify-handle-icon">{{ success ? '✔' : '' }}</text>
</view>
<text class="s-verify-slider-text">{{ sliderHint }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import sysApi from '@/nx/api/sys'
import { aesEncrypt } from './utils/aes'
const props = defineProps({
captchaType: {
type: String,
default: 'blockPuzzle'
},
mode: {
type: String,
default: 'pop'
}
})
const emits = defineEmits(['success', 'error'])
const visible = ref(false)
const loading = ref(false)
const success = ref(false)
const failAnimate = ref(false)
const verifying = ref(false)
const tipMessage = ref('')
const sliderRef = ref(null)
const sliderWidth = 310
const sliderHeight = 40
const blockWidth = Math.floor((sliderWidth * 47) / 310)
const imageHeight = 155
const dragState = reactive({
startClientX: 0,
baseOffset: 0,
isDragging: false
})
const captchaState = reactive({
originalImageBase64: '',
jigsawImageBase64: '',
token: '',
secretKey: ''
})
const handleOffset = ref(0)
const maxOffset = computed(() => Math.max(sliderWidth - blockWidth, 0))
const showBlock = computed(() => props.captchaType === 'blockPuzzle' && blockImage.value)
const backgroundImage = computed(() =>
captchaState.originalImageBase64 ? `data:image/png;base64,${captchaState.originalImageBase64}` : ''
)
const blockImage = computed(() =>
captchaState.jigsawImageBase64 ? `data:image/png;base64,${captchaState.jigsawImageBase64}` : ''
)
const imageStyle = computed(() => ({
width: `${sliderWidth}px`,
height: `${imageHeight}px`
}))
const blockStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${imageHeight}px`,
transform: `translateX(${handleOffset.value}px)`
}))
const sliderHandleStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${sliderHeight}px`,
transform: `translateX(${handleOffset.value}px)`
}))
const sliderFillStyle = computed(() => ({
width: `${handleOffset.value > 0 ? Math.min(handleOffset.value + blockWidth, sliderWidth) : 0}px`
}))
const sliderHint = computed(() => {
if (success.value) {
return '验证通过'
}
if (loading.value) {
return '验证码加载中'
}
return '请按住滑块,拖动完成拼图'
})
const tipClass = computed(() => {
if (success.value) return 's-verify-tip-success'
if (failAnimate.value) return 's-verify-tip-error'
return ''
})
let closeTimer = null
let mouseMoveListener = null
let mouseUpListener = null
function show() {
if (visible.value) return
visible.value = true
resetState()
nextTick(() => {
loadCaptcha()
})
}
function handleClose() {
visible.value = false
cleanupMouseListeners()
clearCloseTimer()
}
function refreshCaptcha() {
if (loading.value) return
resetSlider()
loadCaptcha()
}
function resetSlider() {
handleOffset.value = 0
dragState.baseOffset = 0
dragState.startClientX = 0
dragState.isDragging = false
success.value = false
failAnimate.value = false
tipMessage.value = ''
verifying.value = false
}
function resetState() {
captchaState.originalImageBase64 = ''
captchaState.jigsawImageBase64 = ''
captchaState.token = ''
captchaState.secretKey = ''
resetSlider()
}
async function loadCaptcha() {
loading.value = true
try {
const res = await sysApi.getCaptchaCode({ captchaType: props.captchaType })
if (res && (res.repCode === '0000' || res.code === 0)) {
const data = res.repData || res.data || {}
captchaState.originalImageBase64 = data.originalImageBase64 || ''
captchaState.jigsawImageBase64 = data.jigsawImageBase64 || ''
captchaState.token = data.token || ''
captchaState.secretKey = data.secretKey || ''
} else {
tipMessage.value = res?.repMsg || res?.msg || '验证码获取失败,请重试'
emits('error', res)
}
} catch (error) {
console.error('captcha load error', error)
tipMessage.value = '验证码加载异常,请稍后再试'
emits('error', error)
} finally {
loading.value = false
}
}
function onDragStart(event) {
if (loading.value || success.value) return
dragState.isDragging = true
dragState.startClientX = getClientX(event)
dragState.baseOffset = handleOffset.value
failAnimate.value = false
tipMessage.value = ''
}
function onDragMove(event) {
if (!dragState.isDragging) return
const currentX = getClientX(event)
const delta = currentX - dragState.startClientX
let next = dragState.baseOffset + delta
if (next < 0) next = 0
if (next > maxOffset.value) next = maxOffset.value
handleOffset.value = next
}
function onDragEnd() {
if (!dragState.isDragging) return
dragState.isDragging = false
submitVerification()
}
function onMouseDown(event) {
if (event?.button !== 0) return
if (typeof window !== 'undefined') {
mouseMoveListener = ev => {
onDragMove(ev)
ev.preventDefault()
}
mouseUpListener = ev => {
cleanupMouseListeners()
onDragEnd(ev)
}
window.addEventListener('mousemove', mouseMoveListener)
window.addEventListener('mouseup', mouseUpListener)
}
onDragStart(event)
}
function cleanupMouseListeners() {
if (typeof window === 'undefined') return
if (mouseMoveListener) {
window.removeEventListener('mousemove', mouseMoveListener)
mouseMoveListener = null
}
if (mouseUpListener) {
window.removeEventListener('mouseup', mouseUpListener)
mouseUpListener = null
}
}
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
async function submitVerification() {
if (verifying.value) return
verifying.value = true
failAnimate.value = false
const point = {
x: Math.round(handleOffset.value),
y: 5.0
}
try {
const payload = captchaState.secretKey
? aesEncrypt(JSON.stringify(point), captchaState.secretKey)
: JSON.stringify(point)
const res = await sysApi.verifyCaptcha({
captchaType: props.captchaType,
token: captchaState.token,
pointJson: payload
})
if (res && (res.repCode === '0000' || res.code === 0)) {
success.value = true
tipMessage.value = '验证成功'
const captchaVerification = captchaState.secretKey
? aesEncrypt(`${captchaState.token}---${JSON.stringify(point)}`, captchaState.secretKey)
: `${captchaState.token}---${JSON.stringify(point)}`
emits('success', {
captchaVerification,
token: captchaState.token,
point
})
clearCloseTimer()
closeTimer = setTimeout(() => {
handleClose()
}, 800)
} else {
handleFail(res?.repMsg || res?.msg || '验证失败,请重试')
}
} catch (error) {
console.error('captcha verify error', error)
handleFail('网络异常,请重试')
} finally {
verifying.value = false
}
}
function handleFail(message) {
failAnimate.value = true
tipMessage.value = message
emits('error', message)
setTimeout(() => {
failAnimate.value = false
}, 400)
setTimeout(() => {
refreshCaptcha()
}, 500)
}
function getClientX(event) {
if (event?.touches && event.touches.length) {
return event.touches[0].clientX
}
if (event?.changedTouches && event.changedTouches.length) {
return event.changedTouches[0].clientX
}
return event?.clientX || 0
}
onBeforeUnmount(() => {
cleanupMouseListeners()
clearCloseTimer()
})
defineExpose({
show,
close: handleClose,
refresh: refreshCaptcha
})
</script>
<style lang="scss" scoped>
.s-verify-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.s-verify-wrapper {
width: 340px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.s-verify-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f2f3f5;
}
.s-verify-title {
font-size: 16px;
font-weight: 600;
color: #1f2d3d;
}
.s-verify-close {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
color: #999999;
font-size: 18px;
}
.s-verify-body {
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.s-verify-image {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
}
.s-verify-image-bg {
width: 100%;
height: 100%;
display: block;
}
.s-verify-image-block {
position: absolute;
top: 0;
display: block;
}
.s-verify-refresh {
position: absolute;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.s-verify-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.65);
font-size: 14px;
color: #666666;
}
.s-verify-tip {
width: 100%;
text-align: center;
font-size: 14px;
padding: 6px 0;
border-radius: 6px;
}
.s-verify-tip-success {
color: #52c41a;
background: rgba(82, 196, 26, 0.12);
}
.s-verify-tip-error {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.12);
}
.s-verify-slider {
position: relative;
width: 310px;
height: 40px;
border-radius: 20px;
background: #f2f3f5;
overflow: hidden;
}
.s-verify-slider-track {
position: absolute;
inset: 0;
border-radius: 20px;
background: #f2f3f5;
}
.s-verify-slider-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--ui-BG-Main, #409eff);
border-radius: 20px;
transition: width 0.05s linear;
}
.s-verify-slider-handle {
position: absolute;
top: 0;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 6px 14px rgba(25, 87, 170, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: #1f2d3d;
transition: transform 0.05s linear;
}
.s-verify-slider-success {
background: #52c41a;
color: #ffffff;
}
.s-verify-slider-fail {
animation: s-verify-shake 0.3s;
}
.s-verify-handle-icon {
font-size: 22px;
font-weight: 600;
}
.s-verify-slider-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 14px;
}
@keyframes s-verify-shake {
0% {
transform: translateX(-4px);
}
25% {
transform: translateX(4px);
}
50% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,17 @@
import CryptoJS from 'crypto-js';
/**
* 使用 AES-ECB 模式 + PKCS7 填充对字符串进行加密
* @param {string} word - 需要加密的明文
* @param {string} [keyWord='XwKsGlMcdPMEhR1B'] - 加密密钥,默认为 aj-captcha 默认密钥
* @returns {string} - 加密后的密文
*/
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
const key = CryptoJS.enc.Utf8.parse(keyWord);
const srcs = CryptoJS.enc.Utf8.parse(word);
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}

View File

@@ -0,0 +1,227 @@
/**
* 验证码组件工具函数
*/
/**
* 生成随机整数
* @param {number} min 最小值
* @param {number} max 最大值
* @returns {number} 随机整数
*/
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 生成随机颜色
* @returns {string} 十六进制颜色值
*/
export function randomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* 生成随机字符串
* @param {number} length 字符串长度
* @param {string} chars 字符集
* @returns {string} 随机字符串
*/
export function randomString(length = 4, chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678') {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 计算两点距离
* @param {number} x1 点1 x坐标
* @param {number} y1 点1 y坐标
* @param {number} x2 点2 x坐标
* @param {number} y2 点2 y坐标
* @returns {number} 距离
*/
export function getDistance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
* 判断点是否在矩形区域内
* @param {number} x 点x坐标
* @param {number} y 点y坐标
* @param {Object} rect 矩形对象 {x, y, width, height}
* @returns {boolean} 是否在区域内
*/
export function isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
/**
* 生成验证码图片拼图
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} x x坐标
* @param {number} y y坐标
* @param {number} r 圆角半径
* @param {number} PI 圆周率
*/
export function drawPath(ctx, x, y, r, PI) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + r / 2, y - r + 2, r, 0, 2 * PI);
ctx.lineTo(x + r, y);
ctx.arc(x + r + r / 2, y + r / 2, r, 1.5 * PI, 0.5 * PI);
ctx.lineTo(x + r, y + r);
ctx.lineTo(x, y + r);
ctx.arc(x + r / 2, y + r + r / 2, r, PI, 0);
ctx.closePath();
}
/**
* 获取图片像素数据
* @param {string} imgSrc 图片源
* @returns {Promise<ImageData>} 图片像素数据
*/
export function getImageData(imgSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
};
img.onerror = reject;
img.crossOrigin = 'anonymous';
img.src = imgSrc;
});
}
/**
* 创建干扰线
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} lineCount 干扰线数量
*/
export function drawInterferenceLines(ctx, width, height, lineCount = 8) {
for (let i = 0; i < lineCount; i++) {
ctx.strokeStyle = randomColor();
ctx.lineWidth = randomInt(1, 3);
ctx.beginPath();
ctx.moveTo(randomInt(0, width), randomInt(0, height));
ctx.lineTo(randomInt(0, width), randomInt(0, height));
ctx.stroke();
}
}
/**
* 创建干扰点
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} pointCount 干扰点数量
*/
export function drawInterferencePoints(ctx, width, height, pointCount = 100) {
for (let i = 0; i < pointCount; i++) {
ctx.fillStyle = randomColor();
ctx.beginPath();
ctx.arc(randomInt(0, width), randomInt(0, height), randomInt(1, 2), 0, 2 * Math.PI);
ctx.fill();
}
}
/**
* 节流函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 节流后的函数
*/
export function throttle(func, delay) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, args);
timer = null;
}, delay);
}
};
}
/**
* 防抖函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 防抖后的函数
*/
export function debounce(func, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
/**
* 重置画布
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
*/
export function resetCanvas(ctx, width, height) {
ctx.clearRect(0, 0, width, height);
}
/**
* 获取元素的绝对位置
* @param {Element} element DOM元素
* @returns {Object} {left, top}
*/
export function getElementPosition(element) {
let left = 0;
let top = 0;
while (element) {
left += element.offsetLeft;
top += element.offsetTop;
element = element.offsetParent;
}
return { left, top };
}
/**
* 移动端触摸事件坐标获取
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getTouchPosition(e) {
const touch = e.touches && e.touches.length > 0 ? e.touches[0] : e.changedTouches[0];
return {
x: touch.clientX,
y: touch.clientY
};
}
/**
* 获取鼠标或触摸位置
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getEventPosition(e) {
if (e.touches) {
return getTouchPosition(e);
}
return {
x: e.clientX,
y: e.clientY
};
}
export { aesEncrypt } from './aes';

View File

@@ -0,0 +1,58 @@
<template>
<up-navbar
v-bind="$attrs"
:bgColor="bgColor"
titleStyle="color:#fff"
leftIconColor="#fff"
:left-text="leftText"
:title="title"
placeholder
:autoBack="autoBack"
:leftIcon="leftIcon"
@rightClick="rightClick"
@leftClick="leftClick"
>
<template #right>
<slot></slot>
</template>
</up-navbar>
</template>
<script setup>
const emits = defineEmits(['leftClick', 'rightClick'])
const props = defineProps({
title: {
type: String,
required: true
},
autoBack: {
type: Boolean,
default: true
},
color: {
type: String,
default: '#fff'
},
leftIcon: {
type: String,
default: 'arrow-left'
},
leftText: {
type: String,
default: '返回'
},
bgColor: {
type: String,
default: '#0055A2'
}
})
function rightClick() {
emits('rightClick')
}
function leftClick() {
emits('leftClick')
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,284 @@
<template>
<view>
<u-popup :show="showAuncelSelector" closeable @close="close" @open="open" mode="right">
<view class="p10">天平选择</view>
<scroll-view scroll-y="true" class="content">
<u-grid border :col="3" @click="doSelect">
<u-grid-item v-for="(auncel, index) in auncelList" :index="index" :key="index">
<view class="auncel-item">
<view class="auncel-name">
{{ auncel.code }}
<view style="text-align: center">{{ auncel.controlRealName }}</view>
</view>
<view class="weight">
<view
:class="
auncel.weightStable === 0
? 'weight-data-yellow'
: auncel.weightStable === 1
? 'weight-data'
: 'weight-data-warning'
"
>
{{ auncel.weightData }}
</view>
<view class="weight-unit">{{ auncel.weightUnit }}</view>
</view>
</view>
</u-grid-item>
</u-grid>
</scroll-view>
</u-popup>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import nx from '@/nx' // 假设你的全局状态/工具挂载在 nx
// Props & Emits
const props = defineProps({
showAuncelSelector: {
type: Boolean,
default: false
},
previousAuncelId: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:showAuncelSelector'])
// 响应式数据
const auncelList = ref([])
// 计算属性:获取用户信息
const userInfo = computed(() => nx.$store('user').userInfo)
// 方法
const open = () => {
getPageData()
listenDeviceData()
}
const close = () => {
console.log('auncel-selector close触发')
auncelList.value = []
uni.$emit('auncelSelector_close')
closeDeviceListener()
emit('update:showAuncelSelector', false)
}
const getPageData = () => {
uni.showLoading({ title: '加载中...' })
nx.$api.laboratory
.getDeviceLaboratoryListBy({
deviceType: 'auncel',
pageNo: 0,
pageSize: -1,
status: '1',
isEnable: '1'
})
.then(res => {
const dataList = res.records.map(item => ({
...item,
weightData: '',
weightUnit: '',
isConnected: 0,
weightStable: 0,
temperature: 0,
humidity: 0,
controlRealName: ''
}))
auncelList.value = dataList
uni.hideLoading()
})
.catch(err => {
console.error('加载天平列表失败:', err)
uni.hideLoading()
})
}
const doSelect = index => {
const currentAuncel = auncelList.value[index]
if (currentAuncel.isConnected !== 1) {
uni.showToast({ title: '天平设备尚未连接!', icon: 'none' })
return
}
if (currentAuncel.controlRealName && currentAuncel.controlRealName !== userInfo.value.realname) {
uni.showToast({
title: `当前天平正被“${currentAuncel.controlRealName}”使用,请选择其他天平!`,
icon: 'none'
})
return
}
let delayFlag = false
if (props.previousAuncelId && props.previousAuncelId !== '' && props.previousAuncelId !== currentAuncel.id) {
releaseDeviceControl(props.previousAuncelId)
delayFlag = true
}
const controlDevice = {
msgId: currentAuncel.id,
cmd: 'controlDevice',
clientType: 'caaClient',
data: {
deviceId: currentAuncel.id,
deviceCode: currentAuncel.code,
deviceName: currentAuncel.name,
isControl: true,
controlRealName: userInfo.value.realname
}
}
const sendControl = () => {
const controlData = JSON.stringify(controlDevice)
nx.$measure.send(controlData)
console.log('controlDevice', controlData)
uni.$emit('auncelSelector_doSelect', controlDevice)
}
if (delayFlag) {
setTimeout(sendControl, 300)
} else {
sendControl()
}
}
const releaseDeviceControl = deviceId => {
if (!deviceId) return
const controlDevice = {
msgId: deviceId,
cmd: 'controlDevice',
clientType: 'caaClient',
data: {
deviceId,
isControl: false,
controlRealName: userInfo.value.realname
}
}
nx.$measure.send(JSON.stringify(controlDevice))
}
const listenDeviceData = () => {
uni.$on('deviceData', handleDeviceData)
uni.$on('deviceStatus', handleDeviceStatus)
uni.$on('connClose', handleConnClose)
}
const handleDeviceData = res => {
if (res.deviceType !== 'auncel') return
auncelList.value.forEach(item => {
if (item.id === res.deviceId) {
item.weightData = res.weightData ?? ''
item.weightUnit = res.weightUnit ?? ''
item.weightStable = res.weightStable ?? 0
item.isConnected = 1
item.controlRealName = res.controlRealName ?? ''
}
})
}
const handleDeviceStatus = res => {
auncelList.value.forEach(item => {
if (item.id === res.deviceId) {
item.isConnected = res.connected ?? 0
if (res.connected === 0) {
item.weightData = '天平断开'
item.weightUnit = ''
item.weightStable = 0
}
}
})
}
const handleConnClose = () => {
auncelList.value.forEach(item => {
item.weightData = ''
item.weightUnit = ''
item.weightStable = 0
item.controlRealName = ''
item.isConnected = 0
})
}
const closeDeviceListener = () => {
uni.$off('deviceData', handleDeviceData)
uni.$off('deviceStatus', handleDeviceStatus)
uni.$off('connClose', handleConnClose)
}
// 生命周期
onMounted(() => {
// 如果组件在 mounted 时已打开,可考虑自动加载(但通常由父组件控制 show
})
onUnmounted(() => {
closeDeviceListener()
})
</script>
<style scoped lang="scss">
.content {
width: 80vw;
height: 90vh;
}
.auncel-item {
height: 180px;
width: 180px;
background-image: url(/static/images/auncel.png);
background-repeat: no-repeat;
background-size: 100%;
display: flex;
flex-direction: column;
}
.auncel-name {
flex: 1;
text-align: center;
padding-top: 20px;
}
.weight {
flex: 1;
display: flex;
flex-direction: row;
font-size: 32px;
letter-spacing: 2px;
padding-top: 10px;
}
.weight-data {
flex: 1;
color: #4cd964;
text-align: right;
font-family: zzjc-lcd;
}
.weight-data-yellow {
flex: 1;
color: #ffff00;
text-align: center;
font-family: zzjc-lcd;
}
.weight-data-warning {
color: #ff3333;
text-align: right;
font-family: zzjc-lcd;
}
.weight-unit {
color: #ffffff;
font-size: 24px;
padding: 0 10px;
}
@media (max-width: 700px) {
.auncel-item {
height: 150px;
width: 150px;
}
.auncel-name {
padding-top: 10px;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<!-- 查看样品详情 -->
<template>
<view>
<u-popup :show="showPopup" @close="close" @open="open" mode="left">
<view class="detail_title">
{{ taskDetail.sampleCode }}
</view>
<view>
<scroll-view scroll-y style="height: 100vh; width: 30vw">
<view style="padding: 10px">
<up-collapse :value="getAllIndexes(fieldGroup)" :accordion="false">
<up-collapse-item
v-for="(fields, groupIndex) in fieldGroup"
:title="fields.title"
:key="groupIndex"
v-if="curParameterKey === '' || curParameterKey === fields.title"
>
<view
class="form-item-my"
v-for="(field, fieldIndex) in fields.fields"
:key="groupIndex + '-' + fieldIndex"
>
<view
class="label-my"
v-html="
field.name +
(typeof field.unit !== 'undefined' && field.unit !== null ? '(' + field.unit + ')' : '')
"
></view>
<view class="content-my">
<view class="content-my-text" v-if="field.type !== 'select'">
<text class="content-my-text-value">{{ field.value }}</text>
</view>
<view class="content-my-text" v-else>
<text class="content-my-text-value">{{ field.valueText }}</text>
</view>
</view>
</view>
</up-collapse-item>
</up-collapse>
</view>
<view class="p30"></view>
</scroll-view>
</view>
</u-popup>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import nx from '@/nx'
// Props
const props = defineProps({
showPopup: {
type: Boolean,
default: false
},
detailPopupParam: {
type: Object,
default: () => ({
taskDetailId: ''
})
}
})
// Data
const curSample = ref({})
const curParameterKey = ref('')
const fieldGroup = ref([])
const taskDetail = ref({})
const optionParameterClassify = ref([])
const conAssayTaskId = ref('')
const busSubCSampleId = ref('')
// Methods
const getAllIndexes = arr => {
return arr.map((_, index) => index)
}
const close = () => {
uni.$emit('sample-detail-popup_close')
}
const getSampleData = () => {
const taskDetailId = props.detailPopupParam.taskDetailId
nx.$api.assayTask.queryFieldsByTaskDetail({ taskDetailId }).then(res => {
fieldGroup.value = res.result
const arr = [{ label: '全部', value: '' }]
for (const g of fieldGroup.value) {
const title = g.title
arr.push({
label: title,
value: title
})
}
optionParameterClassify.value = arr // 字段分类
conAssayTaskId.value = res.additionalProperties.conAssayTaskId
busSubCSampleId.value = res.additionalProperties.busSubCSampleId
taskDetail.value = res.additionalProperties.taskDetail
})
}
const open = () => {
getSampleData()
}
</script>
<style scoped lang="scss">
.form-item-my {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.label-my {
width: 180px; /* 标签宽度 */
display: flex;
align-items: center;
}
.label-my sub {
font-size: 0.6em; /* 调整下标字体大小 */
vertical-align: sub; /* 调整下标垂直对齐 */
}
.content-my {
flex: 1;
padding-left: 7px;
}
.content-my-text {
height: 35px;
display: flex;
align-items: center;
}
.content-my-text-value {
color: rgb(48, 49, 51);
}
.detail_title {
display: flex;
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
font-size: 18px;
font-weight: 400;
padding-top: 30px;
padding-bottom: 10px;
background-color: $uni-color-primary;
color: #fff;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view>
<up-row justify="space-between" v-for="item in commentWf">
<up-col span="6">
<view>
{{ item.nodeName }}<text class="value">{{ item.comment }}</text>
</view>
</up-col>
<up-col span="6">
<view class="y-end">
<text class="value">{{ item.userName }}</text>
<text class="value">{{ item.time }}</text>
</view>
</up-col>
</up-row>
</view>
</template>
<script setup>
const props = defineProps({
commentWf: {
type: Array,
default: () => []
}
})
</script>
<style lang="scss" scoped>
.u-row {
border-bottom: 1px solid #eee;
padding: 10px 0;
}
.value {
color: #666;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<view class="y-f" style="height: 55vh">
<view class="weight">
<view class="weight-data"> {{ nums }}</view>
</view>
<view class="keyboard-container">
<view class="keypad">
<view
class="oner"
:class="item.class"
:style="getListItemStyle(index)"
v-for="(item, index) in numbers"
:key="'num_' + index"
@click="changeNums(item, index)"
>
{{ item.text }}
</view>
</view>
<view class="func-pad">
<view @click="jianshao()" class="oner flex1">
<u-icon name="arrow-leftward" bold></u-icon>
</view>
<view @click="setNull()" class="oner flex1 mt10 mb10"> 清空 </view>
<view class="oner confirm" @click="ok()"> 确认 </view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'zzjc-num-keyboard',
props: {
numKeyboardParam: {
type: Object,
default: null
} //小数位数,-1为不限制
},
data() {
return {
nums: '',
numbers: [
{
text: '1'
},
{
text: '2'
},
{
text: '3'
},
{
text: '4'
},
{
text: '5'
},
{
text: '6'
},
{
text: '7'
},
{
text: '8'
},
{
text: '9'
},
{
text: '0',
class: 'zero'
},
{
text: '.'
}
]
}
},
created() {},
methods: {
//确认
ok() {
const val = {
val: this.nums
}
this.nums = ''
uni.$emit('keyboardOK', val)
},
/*
* 清空
* 数字类型改为0其他类型改为空
* */
setNull() {
this.nums = ''
uni.$emit('keyboardOK', null)
},
clearNum() {
this.nums = ''
},
jianshao() {
if (this.nums) {
this.nums = this.nums.substring(0, this.nums.length - 1)
}
},
changeNums(item, index) {
this.sumindex = index
if (item.text == '.') {
if (this.nums.indexOf('.') != -1 || this.nums.length == 0) {
return false
}
}
//检查小数位数
let decimal = this.numKeyboardParam.decimal
if (decimal == null) decimal = -1
if (this.nums.split('.') && this.nums.split('.')[1] && decimal != -1) {
if (this.nums.split('.')[1].length >= decimal) {
return false
}
}
this.nums = this.nums + item.text
},
getListItemStyle(index) {
return {
background: this.numbers[index].background
}
}
}
}
</script>
<style lang="scss" scoped>
.keyboard-container {
flex: 5;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(232, 232, 232, 0.98);
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 默认 3 列 */
gap: 10px;
padding: 10px;
flex: 4;
}
.oner {
width: 95%;
height: 55px;
font-size: 20px;
border: none;
border-radius: 5px;
background-color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
/* 0 占两列 */
.zero {
width: 98%;
grid-column: span 2;
}
.func-pad {
height: 100%;
display: flex;
padding: 10px 10px 10px 0;
flex-direction: column;
flex: 1;
box-sizing: border-box;
}
.confirm {
flex: 2;
color: #ffffff;
background-color: #19be6b;
}
.weight {
width: 100%;
background-color: #2c405a;
flex: 1;
padding: 8px;
box-sizing: border-box;
}
.weight-data {
color: #4cd964;
text-align: right;
letter-spacing: 5px;
font-size: 50px;
font-family: zzjc-lcd;
height: 40px;
}
@media (max-width: 700px) {
.weight-data {
font-size: 25px;
}
.oner {
height: 33px;
font-size: 16px;
}
.weight-data {
height: 20px;
}
}
</style>