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

1027 lines
26 KiB
Vue
Raw Normal View History

2026-08-31 11:45:14 +08:00
<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>