1027 lines
26 KiB
Vue
1027 lines
26 KiB
Vue
<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>
|