forked from gzt/A8000
10 changed files with 234 additions and 722 deletions
-
2package.json
-
262src/pages/Index/Regular/TestTube.vue
-
1src/pages/Index/components/Consumables/BallGrid.vue
-
165src/pages/Index/components/TestTube/ProjectSetting.vue
-
466src/pages/Index/components/TestTube/TestTubeRack.vue
-
33src/pages/Index/components/TestTube/Tube.vue
-
2src/pages/Index/components/TestTube/index.ts
-
1src/pages/Index/components/index.ts
-
22src/services/Index/Test-tube/test-tube.ts
-
2src/types/Index/TestTube.ts
@ -1,165 +0,0 @@ |
|||
<template> |
|||
<teleport to="body"> |
|||
<div v-if="visible" class="overlay" @click="close"> |
|||
<div class="dialog" @click.stop> |
|||
<div class="dialog-header"> |
|||
<h3>请选择修改的试管类型</h3> |
|||
</div> |
|||
|
|||
<!-- 标签选择 --> |
|||
<div class="tab-container"> |
|||
<div v-for="(option, index) in options" :key="index" class="tab" |
|||
:class="{ active: selectedOption === option }" @click="selectOption(option)"> |
|||
{{ option }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 按钮区域 --> |
|||
<div class="button-container"> |
|||
<button class="cancel-button" @click="cancel">取消</button> |
|||
<button class="confirm-button" @click="confirm">确认</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</teleport> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { ref, watch } from 'vue' |
|||
import { useTestTubeStore } from '../../../../store' |
|||
const testTubeStore = useTestTubeStore() |
|||
const props = defineProps({ |
|||
visible: Boolean, |
|||
selectedProject: String, // 新增选中的项目 |
|||
}) |
|||
watch( |
|||
() => props.selectedProject, |
|||
(newValue) => { |
|||
if (newValue === null) { |
|||
selectedOption.value = options.value[0] // 重置为初始选项 |
|||
} |
|||
}, |
|||
) |
|||
const emit = defineEmits(['update:visible', 'confirm', 'clearSelection']) |
|||
|
|||
const options = ref(['自动', 'BT', 'Epp.0.5', 'Epp.1.5', 'mini', 'Ctip']) |
|||
const selectedOption = ref(options.value[0]) |
|||
|
|||
const selectOption = (option: string) => { |
|||
selectedOption.value = option |
|||
//更新到pinia中 |
|||
testTubeStore.setTypeInfo(option) |
|||
} |
|||
|
|||
const close = () => { |
|||
emit('update:visible', false) |
|||
} |
|||
|
|||
const cancel = () => { |
|||
close() |
|||
} |
|||
|
|||
const confirm = () => { |
|||
emit('confirm', selectedOption.value) |
|||
emit('clearSelection') |
|||
close() |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
z-index: 1000; |
|||
} |
|||
|
|||
.dialog { |
|||
background-color: #fff; |
|||
width: 800px; |
|||
height: 400px; |
|||
padding: 40px; |
|||
border-radius: 10px; |
|||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-around; |
|||
|
|||
.dialog-header { |
|||
h3 { |
|||
margin: 0; |
|||
font-size: 32px; |
|||
font-weight: 600; |
|||
text-align: center; |
|||
} |
|||
} |
|||
|
|||
.tab-container { |
|||
display: flex; |
|||
justify-content: space-around; |
|||
margin-top: 20px; |
|||
|
|||
.tab { |
|||
width: 200px; |
|||
height: 100px; |
|||
line-height: 100px; |
|||
text-align: center; |
|||
background-color: #f5f5f5; |
|||
border-radius: 20px; |
|||
cursor: pointer; |
|||
font-size: 32px; |
|||
color: #478ffe; |
|||
transition: background 0.3s; |
|||
margin: 10px; |
|||
|
|||
&.active { |
|||
background-color: #478ffe; |
|||
color: #fff; |
|||
} |
|||
|
|||
&:nth-child(1) { |
|||
background-color: #fff; |
|||
color: #478ffe; |
|||
border: 2px solid #478ffe; |
|||
|
|||
&.active { |
|||
background-color: #478ffe; |
|||
color: #fff; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.button-container { |
|||
display: flex; |
|||
justify-content: space-around; |
|||
margin-top: 30px; |
|||
|
|||
.cancel-button, |
|||
.confirm-button { |
|||
width: 380px; |
|||
height: 100px; |
|||
border-radius: 20px; |
|||
font-size: 45px; |
|||
cursor: pointer; |
|||
border: none; |
|||
|
|||
&.cancel-button { |
|||
background-color: #ebebeb; |
|||
color: #333; |
|||
} |
|||
|
|||
&.confirm-button { |
|||
background-color: #478ffe; |
|||
color: #fff; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
@ -1,442 +1,68 @@ |
|||
<template> |
|||
<div id="tube-container"> |
|||
<div class="tube-box"> |
|||
<div class="tube-rack"> |
|||
<!-- 状态面板 --> |
|||
<div class="status-panel" @click="toggleSetting"> |
|||
<div class="title">{{ projectSetting }}</div> |
|||
<div class="status-icon"> |
|||
<img src="@/assets/Vector.svg" alt="Status Icon" v-if="!isActivated" /> |
|||
<img src="@/assets/Active-Vector.svg" alt="Status Icon" v-else /> |
|||
</div> |
|||
<div class="status-text"> |
|||
<span class="subtitle">{{ handleTip() }}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 样本展示区域 --> |
|||
<div class="samples"> |
|||
<el-popover v-for="(sample, index) in processedTubeSettings" :key="index" trigger="click" |
|||
placement="bottom-start" :ref="'popover-' + index" popper-class="custom-popover" |
|||
:popper-style="getPopoverStyle()"> |
|||
<template #reference> |
|||
<div class="sample-item" :style="[ |
|||
generateSampleBackground(sample.projId!.filter(proj => proj !== null) as ReactionPlate[]), |
|||
getActiveStyle(sample.tubeIndex), |
|||
]" @click="toggleSampleSelection(sample.tubeIndex)" @dblclick="showPopover(index)"> |
|||
<div class="sample-number">{{ index + 1 }}</div> |
|||
<div class="sample-blood-type">{{ sample.bloodType }}</div> |
|||
</div> |
|||
</template> |
|||
<template #default> |
|||
<div class="item-detail"> |
|||
<div v-for="(project, index) in sample.projId" :key="index"> |
|||
<button :style="{ |
|||
backgroundColor: project.color, |
|||
color: 'white', |
|||
border: 'none', |
|||
padding: '10px 20px', |
|||
borderRadius: '5px', |
|||
margin: '2px', |
|||
cursor: 'pointer', |
|||
}"> |
|||
{{ project.projName }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
</el-popover> |
|||
</div> |
|||
</div> |
|||
<!-- 功能按钮区域 --> |
|||
<div class="function-buttons" v-if="isSetting"> |
|||
<div class="active-button" @click="toggleActivate"> |
|||
<div class="icon"> |
|||
<img src="@/assets/outer-ring.svg" alt="" class="outer-ring" /> |
|||
<img src="@/assets/inner-ring.svg" alt="" class="inner-ring" /> |
|||
</div> |
|||
<div class="text"> |
|||
{{ isActivated ? '取消激活' : '激活试管架' }} |
|||
</div> |
|||
</div> |
|||
<!-- 其他功能按钮可继续扩展 --> |
|||
<div class="update-button" @click="showSelector = true"> |
|||
<div class="icon"> |
|||
<img src="@/assets/update-tube-icon.svg" alt="" /> |
|||
</div> |
|||
<div class="text">修改试管类型</div> |
|||
</div> |
|||
<div class="change-user-button" @click="turnChangeUser(tubeRack.uuid)"> |
|||
<div class="icon"> |
|||
<img src="@/assets/update-icon.svg" alt="" /> |
|||
</div> |
|||
<div class="text">编辑患者信息</div> |
|||
</div> |
|||
<div class="del-button" @click="deleteTube(tubeRack.uuid)"> |
|||
<div class="icon"> |
|||
<img src="@/assets/del-icon.svg" alt="" /> |
|||
</div> |
|||
<div class="text">删除试管架</div> |
|||
</div> |
|||
<div class="test-tube-rack"> |
|||
<!--状态--> |
|||
<div class="tube-rack-state"> |
|||
<div class="status-icon"> |
|||
<img src="@/assets/Vector.svg" alt="Status Icon" v-if="tubeRack.state==='INACTIVE'" /> |
|||
<img src="@/assets/Active-Vector.svg" alt="Status Icon" v-else /> |
|||
</div> |
|||
</div> |
|||
<ProjectSetting v-model:visible="showSelector" @confirm="handleConfirm" /> |
|||
<i class="split"></i> |
|||
<!--试管区--> |
|||
<section class="tube-list"> |
|||
<div v-for="(tube, idx) in tubeRack.tubeSettings"> |
|||
<Tube :tube="tube" :index="idx" /> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
<div class="test-tube-rack-op"> |
|||
<div>修改试管架</div> |
|||
<div>编辑患者信息</div> |
|||
<div>删除试管架</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted } from 'vue' |
|||
import ProjectSetting from './ProjectSetting.vue' |
|||
import { ReactionPlate, TubeRack, handleTube, DataItem } from '../../../../types/Index' |
|||
import { |
|||
getBloodTypeLabel, |
|||
processTubeSettings, |
|||
generateSampleBackground, |
|||
} from '../../Utils' |
|||
import { useTestTubeStore } from '../../../../store' |
|||
import { updateTubeActivationStatus } from '../../../../services/index' |
|||
import { ConsumableGroupBase } from '../../../../websocket/socket' |
|||
const testTubeStore = useTestTubeStore() |
|||
// 接收父组件传入的单个试管架数据 |
|||
const props = defineProps<{ |
|||
tubeRack: DataItem |
|||
plates: ConsumableGroupBase[] |
|||
}>() |
|||
onMounted(() => { |
|||
processedTubeSettings.value = processTubeSettings( |
|||
props.tubeRack.tubeSettings, |
|||
props.plates as ConsumableGroupBase[], |
|||
getBloodTypeLabel, |
|||
) |
|||
projectSetting.value = testTubeStore.getProjectSetting(props.tubeRack.uuid) |
|||
isActivated.value = props.tubeRack.active // 同步激活状态 |
|||
}) |
|||
//编辑患者信息 |
|||
const turnChangeUser = (uuid: string) => { |
|||
emits('changeUser', uuid) |
|||
} |
|||
//删除试管架 |
|||
const deleteTube = (uuid: string) => { |
|||
emits('deleteTubeRack', uuid) |
|||
} |
|||
// 获取选中样本的样式 |
|||
const getActiveStyle = (tubeIndex: number) => { |
|||
return props.tubeRack.selectedSampleIds!.includes(tubeIndex) |
|||
? selectedStyle |
|||
: {} |
|||
} |
|||
|
|||
const processedTubeSettings = ref<TubeRack[]>([]) |
|||
|
|||
const showSelector = ref(false) |
|||
// 发出事件,告知父组件更新激活状态 |
|||
const emits = defineEmits<{ |
|||
(e: 'updateActivate', update: { uuid: string; active: boolean }): void |
|||
(e: 'changeUser', uuid: string): void |
|||
(e: 'deleteTubeRack', uuid: string): void |
|||
( |
|||
e: 'updateSelectedSamples', |
|||
{ sampleIds, uuid }: { sampleIds: number[]; uuid: string }, |
|||
): void |
|||
}>() |
|||
// 样本选中样式 |
|||
const selectedStyle = { |
|||
outline: '3px solid #4A90E2', // 使用 outline 避免调整大小 |
|||
boxShadow: '0 0 10px rgba(74, 144, 226, 0.6)', |
|||
} |
|||
|
|||
// 切换样本选中状态 |
|||
const toggleSampleSelection = (sampleId: number) => { |
|||
const currentSelected = [...(props.tubeRack.selectedSampleIds || [])] |
|||
const index = currentSelected.indexOf(sampleId) |
|||
|
|||
if (index === -1) { |
|||
currentSelected.push(sampleId) |
|||
} else { |
|||
currentSelected.splice(index, 1) |
|||
} |
|||
|
|||
// 发送更新事件到父组件 |
|||
emits('updateSelectedSamples', { |
|||
sampleIds: currentSelected, |
|||
uuid: props.tubeRack.uuid, |
|||
}) |
|||
} |
|||
|
|||
// 状态变量 |
|||
const isActivated = ref(props.tubeRack.active) |
|||
const isSetting = ref(false) |
|||
const projectSetting = ref(testTubeStore.getProjectSetting(props.tubeRack.uuid)) |
|||
|
|||
// 根据状态面板点击事件切换状态 |
|||
const toggleSetting = () => { |
|||
isSetting.value = !isSetting.value |
|||
} |
|||
|
|||
// 激活或取消激活试管架 |
|||
const toggleActivate = async () => { |
|||
// 切换激活状态 |
|||
isActivated.value = !isActivated.value |
|||
|
|||
// 创建符合 handleTube 类型的数据对象,只更新 active 字段 |
|||
const updatedData: handleTube = { |
|||
uuid: props.tubeRack.uuid, |
|||
active: isActivated.value, |
|||
lock: props.tubeRack.lock, |
|||
tubeSettings: processedTubeSettings.value, // 使用当前试管的完整设置 |
|||
} |
|||
|
|||
// 调用 updateTubeInfo 接口更新激活状态 |
|||
const response = await updateTubeActivationStatus(updatedData) |
|||
|
|||
if (response && response.success) { |
|||
emits('updateActivate', { |
|||
uuid: props.tubeRack.uuid, |
|||
active: isActivated.value, |
|||
}) |
|||
} else { |
|||
console.error('Failed to update activation status') |
|||
// 还原状态 |
|||
isActivated.value = !isActivated.value |
|||
} |
|||
} |
|||
|
|||
// 提示信息 |
|||
const handleTip = () => { |
|||
if (isSetting.value) return '正在配置' |
|||
return isActivated.value ? '已激活' : '未激活' |
|||
} |
|||
// 获取弹出框元素并触发 click 事件 |
|||
const showPopover = (index: number) => { |
|||
const popover = 'popover-' + index |
|||
const popoverElement = document.querySelector( |
|||
`[aria-describedby="${popover}"]`, |
|||
) |
|||
// 使用 HTMLElement 类型断言,确保可以调用 click 方法 |
|||
if (popoverElement instanceof HTMLElement) { |
|||
popoverElement.click() |
|||
} |
|||
} |
|||
|
|||
const handleConfirm = (selectedOption: string) => { |
|||
console.log("选中的数据有", selectedOption) |
|||
projectSetting.value = selectedOption |
|||
showSelector.value = false |
|||
// 更新当前试管架的项目设置到 store |
|||
testTubeStore.setProjectSetting(props.tubeRack.uuid, selectedOption) |
|||
|
|||
// 清除选中状态并通知组件 |
|||
emits('updateSelectedSamples', { |
|||
sampleIds: [], |
|||
uuid: props.tubeRack.uuid, |
|||
}) |
|||
} |
|||
const getPopoverStyle = () => ({ |
|||
width: '100px', |
|||
display: 'flex', |
|||
justifyContent: 'space-between', |
|||
alignItems: 'center', |
|||
}) |
|||
|
|||
// 清空选中状态的方法 |
|||
const clearSelectedSamples = () => { |
|||
if (props.tubeRack.selectedSampleIds) { |
|||
props.tubeRack.selectedSampleIds = [] |
|||
} |
|||
emits('updateSelectedSamples', { |
|||
sampleIds: [], |
|||
uuid: props.tubeRack.uuid |
|||
}) |
|||
} |
|||
|
|||
// 暴露方法给父组件 |
|||
defineExpose({ |
|||
clearSelectedSamples |
|||
}) |
|||
<script setup> |
|||
import Tube from './Tube.vue' |
|||
const props = defineProps(['tubeRack', 'index']) |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
#tube-container { |
|||
<style scoped lang="less"> |
|||
.test-tube-rack { |
|||
background-color: rgb(248, 248, 248); |
|||
height: 150px; |
|||
display: flex; |
|||
align-items: center; |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
flex-direction: column; |
|||
|
|||
.tube-box { |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
width: 100%; |
|||
background-color: #fafafa; |
|||
border-radius: 10px; |
|||
|
|||
.tube-rack { |
|||
display: flex; |
|||
align-items: center; |
|||
width: 100%; |
|||
background-color: #fafafa; |
|||
border-radius: 10px; |
|||
|
|||
// 状态面板 |
|||
.status-panel { |
|||
width: 10%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
position: relative; |
|||
padding: 20px 20px; |
|||
|
|||
.title { |
|||
font-size: 32px; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.status-icon { |
|||
img { |
|||
width: 80px; |
|||
height: 80px; |
|||
} |
|||
} |
|||
|
|||
.status-text { |
|||
.subtitle { |
|||
font-size: 30px; |
|||
font-weight: 400; |
|||
} |
|||
} |
|||
|
|||
&::after { |
|||
content: ''; |
|||
display: block; |
|||
width: 2px; |
|||
height: 60%; |
|||
margin-top: 20px; |
|||
background-color: #000; |
|||
position: absolute; |
|||
right: 0; |
|||
} |
|||
} |
|||
.tube-rack-state { |
|||
width: 100px; |
|||
.status-icon img { |
|||
width: 56px; |
|||
} |
|||
} |
|||
|
|||
// 试管架 |
|||
.samples { |
|||
display: flex; |
|||
align-items: center; |
|||
background-color: #fafafa; |
|||
margin-left: 20px; |
|||
|
|||
.item-detail { |
|||
width: 800px; |
|||
height: 500px; |
|||
} |
|||
|
|||
.sample-item { |
|||
width: 65px; |
|||
height: 65px; |
|||
border-radius: 50%; |
|||
border: 2px solid black; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
margin-right: 10px; |
|||
position: relative; |
|||
flex-direction: column; |
|||
|
|||
.sample-number { |
|||
position: absolute; |
|||
top: -28px; |
|||
font-size: 20px; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.sample-blood-type { |
|||
color: black; |
|||
font-size: 20px; |
|||
font-weight: bold; |
|||
} |
|||
} |
|||
} |
|||
.split { |
|||
width: 1px; |
|||
height: 80px; |
|||
background-color: rgb(192,192,192); |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
// 功能按钮 |
|||
.function-buttons { |
|||
.tube-list { |
|||
flex: 1 1 auto; |
|||
display: flex; |
|||
align-items: center; |
|||
background-color: #ebebeb; |
|||
justify-content: space-evenly; |
|||
width: 100%; |
|||
padding: 20px 0; |
|||
|
|||
.active-button, |
|||
.update-button, |
|||
.change-user-button, |
|||
.del-button { |
|||
width: 200px; |
|||
height: 80px; |
|||
border-radius: 40px; |
|||
margin-right: 10px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
cursor: pointer; |
|||
border: none; |
|||
position: relative; |
|||
|
|||
.outer-ring { |
|||
position: absolute; |
|||
width: 30px; |
|||
height: 30px; |
|||
z-index: 1; |
|||
top: 25px; |
|||
left: 25px; |
|||
} |
|||
|
|||
.text { |
|||
font-size: 20px; |
|||
margin-left: 20px; |
|||
} |
|||
|
|||
.inner-ring { |
|||
position: absolute; |
|||
width: 20px; |
|||
height: 20px; |
|||
z-index: 2; |
|||
top: 30px; |
|||
left: 30px; |
|||
} |
|||
} |
|||
|
|||
.active-button, |
|||
.update-button, |
|||
.change-user-button { |
|||
background-color: #478ffe; |
|||
color: #fff; |
|||
} |
|||
|
|||
.del-button { |
|||
background-color: #cd4143; |
|||
color: #fff; |
|||
} |
|||
justify-content: space-around; |
|||
} |
|||
|
|||
.add-tube { |
|||
width: 100%; |
|||
height: 100px; |
|||
background-color: #f6f6f6; |
|||
} |
|||
.test-tube-rack-op { |
|||
height: 68px; |
|||
background-color: rgb(235,235,235); |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 0 40px; |
|||
|
|||
.icon { |
|||
width: 86px; |
|||
height: 86px; |
|||
} |
|||
|
|||
.text { |
|||
font-size: 32px; |
|||
color: #73bc54; |
|||
font-weight: bold; |
|||
} |
|||
} |
|||
font-size: 22px; |
|||
font-weight: 600; |
|||
} |
|||
</style> |
@ -0,0 +1,33 @@ |
|||
<template> |
|||
<div class="tube-item"> |
|||
<span class="order">{{ index + 1 }}</span> |
|||
<div class="tube-circle"> |
|||
<span class="add-symbol">+</span> |
|||
</div> |
|||
<span class="user-id">111</span> |
|||
<!-- <div>{{ tube.bloodType }}</div> --> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup> |
|||
const props = defineProps(['tube', 'index']) |
|||
</script> |
|||
|
|||
<style scoped lang="less"> |
|||
.tube-circle { |
|||
width: 72px; |
|||
height: 72px; |
|||
border-radius: 999px; |
|||
border: solid 1px gray; |
|||
|
|||
background-color: #fff; |
|||
|
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
.add-symbol { |
|||
color: black; |
|||
font-size: 20px; |
|||
} |
|||
} |
|||
</style> |
@ -1,2 +0,0 @@ |
|||
export { default as ProjectSetting } from './ProjectSetting.vue' |
|||
export { default as TestTubeRack } from './TestTubeRack.vue' |
Write
Preview
Loading…
Cancel
Save
Reference in new issue