Files
MrleeAdmin/frontend/web/app/apps/produce/pages/data-entry/index.vue
T

1027 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="produce-data-entry-page">
<div class="page-shell">
<div class="workspace-layout">
<aside class="workspace-sidebar">
<div class="sidebar-panel">
<div class="sidebar-section">
<div class="section-heading">
<h1>数据扫码录入</h1>
<p>保持单点聚焦,快速完成录入。</p>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">工作模式</div>
<div class="mode-grid">
<button
type="button"
class="select-chip"
:class="{ 'is-active': workMode === 'order' }"
@click="workMode = 'order'"
>
订单模式
</button>
<button
type="button"
class="select-chip"
:class="{ 'is-active': workMode === 'machine' }"
@click="workMode = 'machine'"
>
整机模式
</button>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">工序选择</div>
<div class="vertical-options">
<button
v-for="item in processList"
:key="item.value"
type="button"
class="option-button"
:class="{ 'is-active': String(selectedProcess) === String(item.value) }"
@click="handleProcessChange(item.value)"
>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">步骤选择</div>
<div class="vertical-options">
<button
v-for="item in stepList"
:key="item.value"
type="button"
class="option-button"
:class="{ 'is-active': String(selectedStep) === String(item.value) }"
@click="handleStepChange(item.value)"
>
<span>{{ item.label }}</span>
</button>
<div v-if="!stepList.length" class="empty-tip">请先选择工序</div>
</div>
</div>
<div class="sidebar-section compact-section">
<div class="section-title">扫码模式</div>
<div class="mode-grid muted-grid">
<button
type="button"
class="select-chip ghost-chip"
:class="{ 'is-active': scanMode === 'manual' }"
@click="scanMode = 'manual'"
>
手动输入
</button>
<button
type="button"
class="select-chip ghost-chip"
:class="{ 'is-active': scanMode === 'scanner' }"
@click="scanMode = 'scanner'"
>
扫码枪
</button>
</div>
</div>
<div class="sidebar-section compact-section">
<div class="section-title">成功提示音</div>
<div class="sound-grid tone-grid">
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': successTone === 'tone1' }"
@click="selectSuccessTone('tone1')"
>
提示音1
</button>
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': successTone === 'tone2' }"
@click="selectSuccessTone('tone2')"
>
提示音2
</button>
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': successTone === 'tone3' }"
@click="selectSuccessTone('tone3')"
>
提示音3
</button>
</div>
</div>
<div class="sidebar-section compact-section">
<div class="section-title">错误提示音</div>
<div class="sound-grid tone-grid">
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': errorTone === 'error1' }"
@click="selectErrorTone('error1')"
>
错误音1
</button>
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': errorTone === 'error2' }"
@click="selectErrorTone('error2')"
>
错误音2
</button>
<button
type="button"
class="select-chip small-chip ghost-chip"
:class="{ 'is-active': errorTone === 'error3' }"
@click="selectErrorTone('error3')"
>
错误音3
</button>
</div>
</div>
<div class="sidebar-section compact-section">
<div class="section-title">语音提示</div>
<div class="mode-grid muted-grid">
<button
type="button"
class="select-chip ghost-chip"
:class="{ 'is-active': voicePromptEnabled }"
@click="voicePromptEnabled = true"
>
已开启
</button>
<button
type="button"
class="select-chip ghost-chip"
:class="{ 'is-active': !voicePromptEnabled }"
@click="voicePromptEnabled = false"
>
已关闭
</button>
</div>
<div class="sound-grid tone-grid voice-preview-grid">
<button
type="button"
class="select-chip small-chip ghost-chip"
@click="previewVoicePrompt('success')"
>
试听成功
</button>
<button
type="button"
class="select-chip small-chip ghost-chip"
@click="previewVoicePrompt('error')"
>
试听错误
</button>
</div>
</div>
</div>
</aside>
<main class="workspace-main">
<OrderEntryPanel
v-if="workMode === 'order'"
ref="orderEntryPanelRef"
:selected-process="selectedProcess"
:selected-step="selectedStep"
:process-label="activeProcessLabel"
:step-label="activeStepLabel"
:scan-mode="scanMode"
@play-success="handlePlaySuccess"
@play-error="handlePlayError"
@speak-success="handleSpeakSuccess"
@speak-error="handleSpeakError"
/>
<MachineEntryPanel
v-else
ref="machineEntryPanelRef"
:selected-process="selectedProcess"
:selected-step="selectedStep"
:process-label="activeProcessLabel"
:step-label="activeStepLabel"
:scan-mode="scanMode"
@play-success="handlePlaySuccess"
@play-error="handlePlayError"
@speak-success="handleSpeakSuccess"
@speak-error="handleSpeakError"
/>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MachineEntryPanel from './components/MachineEntryPanel.vue'
import OrderEntryPanel from './components/OrderEntryPanel.vue'
import {
getProcessOptions,
getStepOptions,
type ProduceOption,
} from '~/apps/produce/api/data-entry'
type WorkMode = 'order' | 'machine'
type EntryPanelExpose = {
focusPrimaryInput?: () => void
submitEntry?: () => Promise<void> | void
resetForm?: () => void
}
const STORAGE_KEY_PROCESS = 'produce_web_data_entry_process'
const STORAGE_KEY_STEP = 'produce_web_data_entry_step'
const STORAGE_KEY_SCAN_MODE = 'produce_web_data_entry_scan_mode'
const STORAGE_KEY_WORK_MODE = 'produce_web_data_entry_work_mode'
const STORAGE_KEY_SUCCESS_TONE = 'produce_web_data_entry_success_tone'
const STORAGE_KEY_ERROR_TONE = 'produce_web_data_entry_error_tone'
const STORAGE_KEY_VOICE_PROMPT = 'produce_web_data_entry_voice_prompt'
const workMode = ref<WorkMode>('order')
const scanMode = ref<'manual' | 'scanner'>('manual')
const processList = ref<ProduceOption[]>([])
const stepList = ref<ProduceOption[]>([])
const selectedProcess = ref<string | number>('')
const selectedStep = ref<string | number>('')
const successTone = ref<'tone1' | 'tone2' | 'tone3'>('tone1')
const errorTone = ref<'error1' | 'error2' | 'error3'>('error1')
const voicePromptEnabled = ref(true)
const orderEntryPanelRef = ref<EntryPanelExpose>()
const machineEntryPanelRef = ref<EntryPanelExpose>()
const activeProcessLabel = computed(() => processList.value.find((item: ProduceOption) => String(item.value) === String(selectedProcess.value))?.label || '')
const activeStepLabel = computed(() => stepList.value.find((item: ProduceOption) => String(item.value) === String(selectedStep.value))?.label || '')
const activeEntryPanel = computed<EntryPanelExpose | undefined>(() => {
return workMode.value === 'machine' ? machineEntryPanelRef.value : orderEntryPanelRef.value
})
let toneAudioContext: AudioContext | null = null
type ToneNote = {
frequency: number
duration: number
type: OscillatorType
gain: number
}
const successToneNotes: Record<'tone1' | 'tone2' | 'tone3', ToneNote[]> = {
tone1: [
{ frequency: 880, duration: 0.08, type: 'sine', gain: 0.05 },
{ frequency: 1174, duration: 0.12, type: 'sine', gain: 0.06 },
],
tone2: [
{ frequency: 784, duration: 0.07, type: 'triangle', gain: 0.05 },
{ frequency: 988, duration: 0.07, type: 'triangle', gain: 0.05 },
{ frequency: 1318, duration: 0.12, type: 'triangle', gain: 0.06 },
],
tone3: [
{ frequency: 1046, duration: 0.1, type: 'square', gain: 0.04 },
{ frequency: 1568, duration: 0.14, type: 'square', gain: 0.045 },
],
}
const errorToneNotes: Record<'error1' | 'error2' | 'error3', ToneNote[]> = {
error1: [
{ frequency: 392, duration: 0.11, type: 'sawtooth', gain: 0.05 },
{ frequency: 294, duration: 0.16, type: 'sawtooth', gain: 0.05 },
],
error2: [
{ frequency: 330, duration: 0.08, type: 'square', gain: 0.045 },
{ frequency: 262, duration: 0.08, type: 'square', gain: 0.045 },
{ frequency: 220, duration: 0.18, type: 'square', gain: 0.05 },
],
error3: [
{ frequency: 260, duration: 0.16, type: 'triangle', gain: 0.045 },
{ frequency: 220, duration: 0.2, type: 'triangle', gain: 0.05 },
],
}
const focusActivePanelPrimaryInput = () => {
setTimeout(() => activeEntryPanel.value?.focusPrimaryInput?.(), 80)
}
const getToneAudioContext = async () => {
if (typeof window === 'undefined') return null
const AudioContextConstructor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextConstructor) return null
if (!toneAudioContext) {
toneAudioContext = new AudioContextConstructor()
}
if (toneAudioContext.state === 'suspended') {
await toneAudioContext.resume()
}
return toneAudioContext
}
const playToneSequence = async (notes: ToneNote[]) => {
const audioContext = await getToneAudioContext()
if (!audioContext) return
let startTime = audioContext.currentTime + 0.01
notes.forEach((note) => {
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.type = note.type
oscillator.frequency.setValueAtTime(note.frequency, startTime)
gainNode.gain.setValueAtTime(0.0001, startTime)
gainNode.gain.linearRampToValueAtTime(note.gain, startTime + 0.01)
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + note.duration)
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.start(startTime)
oscillator.stop(startTime + note.duration + 0.02)
startTime += note.duration + 0.04
})
}
const playSuccessTone = async (tone: 'tone1' | 'tone2' | 'tone3' = successTone.value) => {
if (voicePromptEnabled.value) return
await playToneSequence(successToneNotes[tone])
}
const playErrorTone = async (tone: 'error1' | 'error2' | 'error3' = errorTone.value) => {
if (voicePromptEnabled.value) return
await playToneSequence(errorToneNotes[tone])
}
const selectSuccessTone = (tone: 'tone1' | 'tone2' | 'tone3') => {
successTone.value = tone
void playSuccessTone(tone)
}
const selectErrorTone = (tone: 'error1' | 'error2' | 'error3') => {
errorTone.value = tone
void playErrorTone(tone)
}
const speakVoicePrompt = (text: '成功' | '错误') => {
if (!voicePromptEnabled.value || typeof window === 'undefined' || !('speechSynthesis' in window)) return
window.speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = 'zh-CN'
utterance.rate = 1
utterance.pitch = 1
utterance.volume = 1
window.speechSynthesis.speak(utterance)
}
const previewVoicePrompt = (type: 'success' | 'error') => {
speakVoicePrompt(type === 'success' ? '成功' : '错误')
}
const handlePlaySuccess = () => {
void playSuccessTone()
}
const handlePlayError = () => {
void playErrorTone()
}
const handleSpeakSuccess = () => {
speakVoicePrompt('成功')
}
const handleSpeakError = () => {
speakVoicePrompt('错误')
}
const saveLocalState = () => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY_WORK_MODE, workMode.value)
localStorage.setItem(STORAGE_KEY_SCAN_MODE, scanMode.value)
localStorage.setItem(STORAGE_KEY_SUCCESS_TONE, successTone.value)
localStorage.setItem(STORAGE_KEY_ERROR_TONE, errorTone.value)
localStorage.setItem(STORAGE_KEY_VOICE_PROMPT, String(voicePromptEnabled.value))
if (selectedProcess.value) localStorage.setItem(STORAGE_KEY_PROCESS, String(selectedProcess.value))
if (selectedStep.value) localStorage.setItem(STORAGE_KEY_STEP, String(selectedStep.value))
}
const restoreLocalState = () => {
if (typeof window === 'undefined') return
const savedWorkMode = localStorage.getItem(STORAGE_KEY_WORK_MODE)
if (savedWorkMode === 'order' || savedWorkMode === 'machine') workMode.value = savedWorkMode
const savedScanMode = localStorage.getItem(STORAGE_KEY_SCAN_MODE)
if (savedScanMode === 'manual' || savedScanMode === 'scanner') scanMode.value = savedScanMode
const savedSuccessTone = localStorage.getItem(STORAGE_KEY_SUCCESS_TONE)
if (savedSuccessTone === 'tone1' || savedSuccessTone === 'tone2' || savedSuccessTone === 'tone3') successTone.value = savedSuccessTone
const savedErrorTone = localStorage.getItem(STORAGE_KEY_ERROR_TONE)
if (savedErrorTone === 'error1' || savedErrorTone === 'error2' || savedErrorTone === 'error3') errorTone.value = savedErrorTone
voicePromptEnabled.value = localStorage.getItem(STORAGE_KEY_VOICE_PROMPT) !== 'false'
}
const loadStepList = async (processId?: string | number) => {
stepList.value = await getStepOptions(processId).catch(() => [])
}
const loadProcessList = async () => {
processList.value = await getProcessOptions().catch(() => [])
}
const restoreSelections = async () => {
const savedProcess = localStorage.getItem(STORAGE_KEY_PROCESS)
const processValue = processList.value.find((item: ProduceOption) => String(item.value) === savedProcess)?.value || processList.value[0]?.value || ''
selectedProcess.value = processValue
if (!processValue) return
await loadStepList(processValue)
const savedStep = localStorage.getItem(STORAGE_KEY_STEP)
const stepValue = stepList.value.find((item: ProduceOption) => String(item.value) === savedStep)?.value || stepList.value[0]?.value || ''
selectedStep.value = stepValue
}
const handleProcessChange = async (value: string | number) => {
selectedProcess.value = value
selectedStep.value = ''
await loadStepList(value)
selectedStep.value = stepList.value[0]?.value || ''
saveLocalState()
focusActivePanelPrimaryInput()
}
const handleStepChange = async (value: string | number) => {
selectedStep.value = value
saveLocalState()
focusActivePanelPrimaryInput()
}
watch([workMode, scanMode, successTone, errorTone, voicePromptEnabled], saveLocalState)
onMounted(async () => {
restoreLocalState()
await loadProcessList()
await restoreSelections()
focusActivePanelPrimaryInput()
})
watch(workMode, async () => {
saveLocalState()
await nextTick()
focusActivePanelPrimaryInput()
})
</script>
<style scoped>
.produce-data-entry-page {
min-height: calc(100vh - 120px);
background: #f5f7fb;
padding: 24px;
}
.page-shell {
max-width: 1440px;
margin: 0 auto;
}
.workspace-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.workspace-sidebar {
position: relative;
min-height: 1px;
}
.sidebar-panel {
position: fixed;
top: 88px;
left: max(24px, calc((100vw - 1488px) / 2 + 24px));
width: 280px;
z-index: 10;
background: #e8f0fb;
border-radius: 20px;
padding: 22px;
display: flex;
flex-direction: column;
gap: 18px;
max-height: calc(100vh - 112px);
overflow-y: auto;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-heading h1 {
margin: 0;
font-size: 28px;
line-height: 1.2;
color: #1f2937;
}
.section-heading p {
margin: 8px 0 0;
font-size: 14px;
color: #64748b;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.sound-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.muted-grid {
gap: 8px;
}
.tone-grid {
gap: 0;
border: 1px solid #d8e1ee;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
overflow: hidden;
}
.voice-preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.select-chip,
.option-button {
border: 1px solid #dde5f0;
background: rgba(255, 255, 255, 0.88);
color: #475569;
border-radius: 12px;
transition: all 0.2s ease;
cursor: pointer;
}
.select-chip {
min-height: 44px;
padding: 0 14px;
font-size: 14px;
font-weight: 500;
}
.small-chip {
min-height: 38px;
padding: 0 10px;
font-size: 13px;
}
.ghost-chip {
min-height: 34px;
border-color: #d8e1ee;
background: rgba(255, 255, 255, 0.78);
color: #64748b;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.tone-grid .ghost-chip {
min-height: 34px;
border: 0;
border-right: 1px solid #d8e1ee;
border-radius: 0;
background: transparent;
}
.tone-grid .ghost-chip:last-child {
border-right: 0;
}
.option-button {
min-height: 48px;
padding: 0 16px;
text-align: left;
font-size: 14px;
font-weight: 500;
}
.select-chip:hover,
.option-button:hover {
border-color: #bfd3f5;
background: #ffffff;
color: var(--el-color-primary);
}
.ghost-chip:hover {
border-color: #cfd9e8;
background: rgba(255, 255, 255, 0.96);
color: #475569;
}
.tone-grid .ghost-chip:hover {
background: rgba(241, 245, 249, 0.85);
color: #475569;
}
.select-chip.is-active,
.option-button.is-active {
background: rgba(64, 158, 255, 0.14);
border-color: rgba(64, 158, 255, 0.34);
color: var(--el-color-primary);
box-shadow: none;
}
.ghost-chip.is-active {
background: rgba(64, 158, 255, 0.08);
border-color: rgba(64, 158, 255, 0.22);
color: #3b82f6;
}
.tone-grid .ghost-chip.is-active {
background: rgba(64, 158, 255, 0.08);
color: #3b82f6;
}
.select-chip.is-success.is-active {
background: rgba(103, 194, 58, 0.14);
border-color: rgba(103, 194, 58, 0.32);
color: var(--el-color-success);
}
.select-chip.is-danger.is-active {
background: rgba(245, 108, 108, 0.14);
border-color: rgba(245, 108, 108, 0.32);
color: var(--el-color-danger);
}
.compact-section .section-title {
font-size: 13px;
color: #334155;
}
.vertical-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.empty-tip {
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px dashed #cbd5e1;
color: #94a3b8;
background: rgba(255, 255, 255, 0.6);
}
.workspace-main {
display: flex;
flex-direction: column;
gap: 20px;
}
.main-top-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1fr);
gap: 20px;
align-items: stretch;
}
.workbench-panel,
.records-panel {
background: #ffffff;
border-radius: 20px;
border: 1px solid #e5e7eb;
padding: 28px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.column-header {
padding-bottom: 18px;
border-bottom: 1px solid #eef2f7;
margin-bottom: 22px;
}
.panel-metric-card {
min-width: 240px;
min-height: 56px;
padding: 12px 16px;
border-radius: 16px;
background: #f8fbff;
border: 1px solid #e3ebf7;
display: flex;
align-items: center;
gap: 12px;
}
.panel-metric-label {
font-size: 13px;
color: #64748b;
}
.panel-metric-value {
font-size: 28px;
line-height: 1;
font-weight: 700;
color: #2563eb;
margin-left: auto;
}
.metric-eye-button {
width: 32px;
height: 32px;
border: 0;
border-radius: 999px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s ease;
}
.metric-eye-button svg {
width: 18px;
height: 18px;
}
.metric-eye-button.is-hidden {
color: #c0cad8;
}
.metric-eye-button:hover {
background: rgba(37, 99, 235, 0.08);
color: #2563eb;
}
.panel-title {
font-size: 18px;
font-weight: 600;
color: #2563eb;
}
.panel-subtitle {
margin-top: 8px;
font-size: 14px;
color: #6b7280;
}
.workbench-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field-row {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.field-row-split {
grid-template-columns: minmax(0, 1fr) 180px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-group-quantity {
min-width: 0;
}
.field-group label {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.field-error-tip {
font-size: 12px;
color: #ef4444;
margin-top: 6px;
}
.field-primary :deep(.el-input__wrapper),
.field-row :deep(.el-input__wrapper),
.field-row :deep(.el-input-number .el-input__wrapper) {
min-height: 58px;
border-radius: 14px;
box-shadow: 0 0 0 1px #dbe4f0 inset;
}
.field-primary :deep(.el-input__wrapper.is-focus),
.field-row :deep(.el-input__wrapper.is-focus),
.field-row :deep(.el-input-number .el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
.quantity-input {
width: 100%;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lock-switch {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #64748b;
}
.action-row {
display: flex;
gap: 12px;
}
.action-row .el-button {
min-width: 160px;
border-radius: 12px;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dashboard-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.dashboard-caption {
font-size: 13px;
color: #94a3b8;
}
.station-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.station-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
background: #ffffff;
border: 1px solid #e6edf6;
}
.station-icon {
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #e8f1ff;
color: #3b82f6;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.station-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.station-info strong {
font-size: 16px;
color: #1f2937;
}
.station-card b {
margin-left: auto;
font-size: 32px;
line-height: 1;
color: #2563eb;
}
.records-header {
margin-bottom: 20px;
}
.records-panel :deep(.el-table) {
--el-table-border-color: #eef2f7;
--el-table-header-bg-color: #f8fafc;
}
.records-panel :deep(.el-table th.el-table__cell) {
font-weight: 600;
color: #475569;
}
.records-panel :deep(.el-table td.el-table__cell) {
padding-top: 14px;
padding-bottom: 14px;
}
@media (max-width: 1024px) {
.workspace-layout {
grid-template-columns: 1fr;
}
.workspace-sidebar {
position: static;
}
.sidebar-panel {
position: static;
top: auto;
left: auto;
width: auto;
z-index: auto;
max-height: none;
overflow-y: visible;
}
.panel-header,
.field-label-row,
.dashboard-header {
flex-direction: column;
align-items: flex-start;
}
.panel-metric-card {
width: 100%;
min-width: 0;
}
.main-top-grid,
.station-grid {
grid-template-columns: 1fr;
}
.field-row-split {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.produce-data-entry-page {
padding: 16px;
}
.sidebar-panel,
.workbench-panel,
.records-panel {
padding: 18px;
border-radius: 16px;
}
.mode-grid,
.sound-grid {
grid-template-columns: 1fr;
}
.action-row {
flex-wrap: wrap;
}
.action-row .el-button {
width: 100%;
}
}
</style>