同步前端功能修改

This commit is contained in:
2026-08-31 11:45:14 +08:00
parent 298a879211
commit e3c7044dfe
21 changed files with 3724 additions and 22 deletions
-6
View File
@@ -1,7 +1 @@
node_modules
*.log
dist
.output
.nuxt
.vercel
app/apps/*
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
View File
View File
+167
View File
@@ -0,0 +1,167 @@
import request from '~/utils/request'
export interface ProduceOption {
label: string
value: string | number
}
export interface ProduceRecordItem {
id?: string | number
order_no?: string
process_order_no?: string
product_code?: string
quantity?: number
user_code?: string
operator?: string
scan_result?: number
scan_date?: string
created_date?: string
process?: {
process_name?: string
}
step?: {
step_name?: string
}
status?: {
status_name?: string
}
}
export interface ProduceSubmitScanDataResult {
code: number
msg: string
data?: {
id?: string | number
}
}
export interface ProduceStatistics {
today_total: number
success_total: number
failed_total: number
}
export interface ProduceUserStatsItem {
user_code: string
operator?: string
count: number
}
export interface ProduceMemberUserCheckResult {
id: number
username: string
nickname: string
}
export interface ProduceDataEntryStepRule {
id?: string | number
process_id?: string | number
process_name?: string
step_code?: string
step_name?: string
prerequisite_steps?: Array<string | number>
prerequisite_mode?: string
prerequisite_require_final?: number
allow_repeat_scan?: number
repeat_scan_statuses?: Array<string | number>
}
export interface ProduceDataEntrySpecificRule {
id?: string | number
rule_name?: string
rule_pattern?: string
rule_type?: number
apply_mode?: 'order' | 'machine' | 'both'
match_field?: 'order_no' | 'process_order_no' | 'product_code' | 'user_code'
step_ids?: Array<string | number>
sort?: number
remark?: string
}
export interface ProduceDataEntryConfig {
basic_setting: Record<string, any>
step_rule: ProduceDataEntryStepRule | null
specific_rules: ProduceDataEntrySpecificRule[]
current?: {
process_id?: string | number
step_id?: string | number
order_no?: string
apply_mode?: 'order' | 'machine' | 'both'
}
}
export function getProcessOptions() {
return request.get<ProduceOption[]>('/produce/process', { format: 'select' })
}
export function getStepOptions(processId?: string | number) {
return request.get<ProduceOption[]>('/produce/process-step', {
format: 'select',
...(processId ? { process_id: processId } : {}),
})
}
export function getStatusOptions(stepId?: string | number) {
return request.get<ProduceOption[]>('/produce/step-status', {
format: 'select',
...(stepId ? { step_id: stepId } : {}),
})
}
export function getProduceConfig(groupCode: string) {
return request.get<Record<string, any>>('/produce/config/info', { group_code: groupCode })
}
export function getDataEntryConfig(params: Record<string, any>) {
return request.get<ProduceDataEntryConfig>('/produce/config/data-entry', params)
}
export function getRecentRecords(params: Record<string, any>) {
return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/scan-data', {
limit: 10,
page: 1,
...params,
})
}
export function getProductRecentRecords(params: Record<string, any>) {
return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/product-record/recent', {
limit: 10,
page: 1,
...params,
})
}
export function getStatistics(params: Record<string, any>) {
return request.get<ProduceStatistics>('/produce/scan-data/statistics', params, { showErrorMessage: false })
}
export function getProductStatistics(params: Record<string, any>) {
return request.get<ProduceStatistics>('/produce/product-record/statistics', params, { showErrorMessage: false })
}
export function getUserCodeStats(params: Record<string, any>) {
return request.get<ProduceUserStatsItem[]>('/produce/scan-data/user-code-stats', params, { showErrorMessage: false })
}
export function getProductUserCodeStats(params: Record<string, any>) {
return request.get<ProduceUserStatsItem[]>('/produce/product-record/user-code-stats', params, { showErrorMessage: false })
}
export function checkMemberUser(userCode: string) {
return request.get<ProduceMemberUserCheckResult>('/produce/member-user/check', { user_code: userCode }, { showErrorMessage: false })
}
export function submitScanData(data: Record<string, any>) {
return request.post<ProduceSubmitScanDataResult>('/produce/scan-data/record', data, {
showErrorMessage: false,
returnFullResponse: true,
})
}
export function submitProductRecord(data: Record<string, any>) {
return request.post<ProduceSubmitScanDataResult>('/produce/product-record/record', data, {
showErrorMessage: false,
returnFullResponse: true,
})
}
+143
View File
@@ -0,0 +1,143 @@
import request from '~/utils/request'
export interface ProduceExceptionSetting {
id: string | number
parent_id?: string | number | null
setting_type: 'type' | 'reason' | 'issue' | string
code?: string
name: string
sort?: number
frequency?: number
remark?: string
}
export interface ProduceExceptionRecord {
id: string | number
order_no?: string
user_code?: string
reporter?: string
type_id?: string | number
type_name?: string
reason_id?: string | number
reason_name?: string
issue_id?: string | number | null
issue_name?: string
images?: string[]
remark?: string
status?: number
status_name?: string
reported_date?: string
handled_date?: string
handler?: string
}
export interface ProduceRepairTask {
id: string | number
device_no: string
type_name?: string
reason_name?: string
issue_name?: string
reporter?: string
user_code?: string
reported_date?: string
status?: number
status_name?: string
}
export interface ProduceRepairPart {
product_code: string
product_name?: string
category_id?: string | number
category_name?: string
source?: 'new' | 'after_sales' | 'refurbished' | string
quantity?: number
fault_description_ids?: Array<string | number>
}
export interface ProduceScanRecord {
id: string | number
process_name?: string
step_name?: string
operator?: string
user_code?: string
}
export function getExceptionSettings() {
return request.get<ProduceExceptionSetting[]>('/produce/exception/settings', {}, { showErrorMessage: false })
}
export function getExceptionStatistics(params: Record<string, any> = {}) {
return request.get<{ pending: number; review: number; done: number }>('/produce/exception/statistics', params, { showErrorMessage: false })
}
export function getRecentExceptions(params: Record<string, any> = {}) {
return request.get<ProduceExceptionRecord[]>('/produce/exception/recent', params, { showErrorMessage: false })
}
export function checkExceptionOrder(orderNo: string) {
return request.get<Record<string, any>>('/produce/exception/order-check', { order_no: orderNo.trim() }, { showErrorMessage: false })
}
export function submitExceptionRecord(data: Record<string, any>) {
return request.post<{ code: number; msg: string; data?: { id: string | number } }>('/produce/exception/record', data, {
showErrorMessage: false,
returnFullResponse: true,
})
}
export function getRepairTasks(params: Record<string, any> = {}) {
return request.get<ProduceRepairTask[]>('/produce/repair-record/tasks', params, { showErrorMessage: false })
}
export function getRepairStatistics(params: Record<string, any> = {}) {
return request.get<{ pending: number; processing: number; completed_today: number }>('/produce/repair-record/statistics', params, { showErrorMessage: false })
}
export function getRepairTask(deviceNo: string) {
return request.get<ProduceRepairTask>('/produce/exception/repair-task', { device_no: deviceNo.trim() }, { showErrorMessage: false })
}
export function getScanRecords(params: Record<string, any> = {}) {
return request.get<{ items: ProduceScanRecord[]; total: number }>('/produce/scan-data', {
limit: 50,
page: 1,
...params,
}, { showErrorMessage: false })
}
export function getMaterialCategories() {
return request.get<Array<{ id: string | number; category_name: string }>>('/produce/scanapp/material-category', {}, { showErrorMessage: false })
}
export function getFaultDescriptionOptions(categoryIds: Array<string | number>) {
return request.get<Array<{ id: string | number; category_id: string | number; description: string }>>(
'/produce/hardware-fault-description/options',
{ category_ids: categoryIds.join(',') },
{ showErrorMessage: false },
)
}
export function lookupMaterial(productCode: string) {
return request.get<{
id: string | number
category_id: string | number
category_name: string
product_code: string
product_name: string
}>('/produce/material/lookup', { product_code: productCode.trim() }, { showErrorMessage: false })
}
export function submitRepairRecord(data: Record<string, any>) {
return request.post<{ code: number; msg: string; data?: { id: string | number; repair_no: string } }>('/produce/repair-record/record', data, {
showErrorMessage: false,
returnFullResponse: true,
})
}
export async function uploadProduceImage(file: File, subDir: 'exception' | 'repair') {
const formData = new FormData()
formData.append('file', file)
formData.append('sub_dir', subDir)
const result = await request.upload<Record<string, any>>('/file/image', formData)
return result?.url || result?.full_url || result?.path || ''
}
+60
View File
@@ -0,0 +1,60 @@
import request from '~/utils/request'
export type ProduceQueryRecordType = 'scan' | 'product' | 'exception' | 'repair'
export interface ProduceQueryRecord {
id: string
record_type: ProduceQueryRecordType
record_type_name: string
order_no?: string
product_code?: string
process_name?: string
step_name?: string
status_name?: string
result_name?: string
operator?: string
user_code?: string
quantity?: number | null
event_at?: number
event_date?: string | null
remark?: string
// 异常提报和维修记录详情
exception_reason?: string
exception_content?: string
material_category?: string
fault_description?: string
}
export interface ProduceQueryPage {
items: ProduceQueryRecord[]
total: number
page: number
limit: number
}
export interface ProduceQueryParams {
record_type: ProduceQueryRecordType
order_no?: string
keyword?: string
user_code?: string
status?: string | number
scan_result?: string | number
start_date?: string
end_date?: string
page?: number
limit?: number
}
export interface ProduceQueryTimeline {
order_no: string
total: number
items: ProduceQueryRecord[]
}
export function queryProduceRecords(params: ProduceQueryParams) {
return request.get<ProduceQueryPage>('/produce/query/records', params, { showErrorMessage: false })
}
export function queryProduceTimeline(orderNo: string) {
return request.get<ProduceQueryTimeline>('/produce/query/timeline', { order_no: orderNo.trim() }, { showErrorMessage: false })
}
@@ -0,0 +1,549 @@
<template>
<div class="entry-mode-panel">
<div class="main-top-grid">
<section class="workbench-panel entry-column-panel">
<div class="panel-header column-header">
<div>
<div class="panel-title">整机扫码输入</div>
<div class="panel-subtitle">当前工序:{{ processLabel || '未选择' }} / {{ stepLabel || '未选择' }}</div>
</div>
<div class="panel-metric-card">
<span class="panel-metric-label">总数</span>
<strong class="panel-metric-value">{{ hideProcessTotal ? '***' : statistics.today_total }}</strong>
<button type="button" class="metric-eye-button" :class="{ 'is-hidden': hideProcessTotal }" @click="hideProcessTotal = !hideProcessTotal">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5C6.5 5 2.12 8.44 1 12c1.12 3.56 5.5 7 11 7s9.88-3.44 11-7c-1.12-3.56-5.5-7-11-7Zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9Zm0-2.5A2 2 0 1 0 12 10a2 2 0 0 0 0 4Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
<div class="workbench-form">
<div class="field-group field-primary">
<label>加工单号</label>
<el-input
ref="processOrderNoInputRef"
v-model="processOrderNo"
clearable
size="large"
placeholder="请扫描或输入加工单号"
@keyup.enter="handleProcessOrderNoComplete"
/>
</div>
<div class="field-row field-row-split">
<div class="field-group">
<label>商品编码</label>
<el-input
ref="productCodeInputRef"
v-model="productCode"
clearable
size="large"
placeholder="请扫描或输入商品编码"
@keyup.enter="handleProductCodeComplete"
/>
</div>
<div class="field-group field-group-quantity">
<label>数量</label>
<el-input-number
ref="quantityInputRef"
v-model="quantity"
:min="1"
:step="1"
size="large"
controls-position="right"
class="quantity-input"
@keyup.enter="handleQuantityComplete"
/>
</div>
</div>
<div class="field-row">
<div class="field-group">
<div class="field-label-row">
<label>用户编码</label>
<div class="lock-switch">
<span>锁定用户编码</span>
<el-switch v-model="autoUserCode" />
</div>
</div>
<el-input
ref="userCodeInputRef"
v-model="userCode"
:disabled="autoUserCode"
clearable
size="large"
placeholder="请扫描或输入用户编码"
@update:model-value="userCode = String($event || '').toUpperCase()"
@input="handleUserCodeInput"
@keyup.enter="handleUserCodeComplete"
@blur="runUserCodeCheck(userCode, true)"
/>
<div v-if="userCodeCheckError" class="field-error-tip">{{ userCodeCheckError }}</div>
</div>
</div>
<div class="action-row">
<el-button type="primary" size="large" :loading="submitting" @click="submitEntry">提交</el-button>
<el-button size="large" @click="resetForm">重置输入</el-button>
</div>
</div>
</section>
<section class="workbench-panel stats-column-panel">
<div class="dashboard-header column-header">
<div class="dashboard-title">工位统计</div>
<span class="dashboard-caption">当日扫码量</span>
</div>
<div v-if="userStats.length" class="station-grid">
<div v-for="item in userStats" :key="item.user_code" class="station-card">
<div class="station-icon">工</div>
<div class="station-info">
<strong>{{ item.user_code }}</strong>
</div>
<b>{{ item.count }}</b>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="72" />
</section>
</div>
<section class="records-panel">
<div class="dashboard-header records-header">
<div class="dashboard-title">最近记录</div>
<span class="dashboard-caption">实时刷新最近 10 条</span>
</div>
<el-table :data="recentRecords" stripe>
<el-table-column prop="process_order_no" label="加工单号" min-width="180" />
<el-table-column prop="product_code" label="商品编码" min-width="180" />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="user_code" label="用户编码" min-width="120" />
<el-table-column label="工序" min-width="140">
<template #default="scope">{{ scope.row.process?.process_name || '-' }}</template>
</el-table-column>
<el-table-column label="步骤" min-width="140">
<template #default="scope">{{ scope.row.step?.step_name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="120">
<template #default="scope">{{ scope.row.status?.status_name || '-' }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="scope">
<el-tag :type="Number(scope.row.scan_result) === 1 ? 'success' : 'danger'">
{{ Number(scope.row.scan_result) === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="120" />
<el-table-column prop="created_date" label="扫码时间" min-width="180" />
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
checkMemberUser,
getDataEntryConfig,
getProductRecentRecords,
getProductStatistics,
getProductUserCodeStats,
submitProductRecord,
type ProduceRecordItem,
type ProduceDataEntrySpecificRule,
type ProduceStatistics,
type ProduceUserStatsItem,
} from '~/apps/produce/api/data-entry'
interface Props {
selectedProcess?: string | number
selectedStep?: string | number
processLabel?: string
stepLabel?: string
scanMode?: 'manual' | 'scanner'
}
const props = withDefaults(defineProps<Props>(), {
selectedProcess: '',
selectedStep: '',
processLabel: '',
stepLabel: '',
scanMode: 'manual',
})
const emit = defineEmits<{
'play-success': []
'play-error': []
'speak-success': []
'speak-error': []
}>()
const STORAGE_KEY_LOCK = 'produce_web_machine_entry_lock'
const STORAGE_KEY_USER_CODE = 'produce_web_machine_entry_user_code'
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_machine_entry_hide_process_total'
const processOrderNo = ref('')
const productCode = ref('')
const quantity = ref(1)
const userCode = ref('')
const autoUserCode = ref(false)
const hideProcessTotal = ref(false)
const submitting = ref(false)
const recentRecords = ref<ProduceRecordItem[]>([])
const userStats = ref<ProduceUserStatsItem[]>([])
const statistics = ref<ProduceStatistics>({ today_total: 0, success_total: 0, failed_total: 0 })
const basicSettings = ref<Record<string, any>>({})
const specificRules = ref<ProduceDataEntrySpecificRule[]>([])
const checkingUserCode = ref(false)
const userCodeCheckError = ref('')
const processOrderNoInputRef = ref()
const productCodeInputRef = ref()
const quantityInputRef = ref()
const userCodeInputRef = ref()
let userCodeCheckTimer: ReturnType<typeof setTimeout> | null = null
let scannerSubmitTimer: ReturnType<typeof setTimeout> | null = null
let statisticsRequestToken = 0
let recentRecordsRequestToken = 0
let dataEntryConfigRequestToken = 0
let initialized = false
const restoreLocalState = () => {
if (typeof window === 'undefined') return
autoUserCode.value = localStorage.getItem(STORAGE_KEY_LOCK) === 'true'
hideProcessTotal.value = localStorage.getItem(STORAGE_KEY_HIDE_PROCESS_TOTAL) === 'true'
if (autoUserCode.value) {
userCode.value = localStorage.getItem(STORAGE_KEY_USER_CODE) || ''
}
}
const saveLocalState = () => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY_LOCK, String(autoUserCode.value))
localStorage.setItem(STORAGE_KEY_HIDE_PROCESS_TOTAL, String(hideProcessTotal.value))
if (autoUserCode.value && userCode.value.trim()) {
localStorage.setItem(STORAGE_KEY_USER_CODE, userCode.value)
} else {
localStorage.removeItem(STORAGE_KEY_USER_CODE)
}
}
const focusPrimaryInput = () => { setTimeout(() => processOrderNoInputRef.value?.focus?.(), 80) }
const focusProductCodeInput = () => { setTimeout(() => productCodeInputRef.value?.focus?.(), 80) }
const focusQuantityInput = () => { setTimeout(() => quantityInputRef.value?.focus?.(), 80) }
const focusUserCodeInput = () => { setTimeout(() => userCodeInputRef.value?.focus?.(), 80) }
const clearScannerSubmitTimer = () => {
if (!scannerSubmitTimer) return
clearTimeout(scannerSubmitTimer)
scannerSubmitTimer = null
}
const handleProcessOrderNoComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (!submitting.value) focusProductCodeInput()
}
const handleProductCodeComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (!submitting.value) focusQuantityInput()
}
const handleQuantityComplete = () => {
if (props.scanMode !== 'scanner') return
if (submitting.value) return
if (autoUserCode.value) {
void submitEntry()
return
}
focusUserCodeInput()
}
const handleUserCodeComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (submitting.value) return
clearScannerSubmitTimer()
userCodeInputRef.value?.blur?.()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
if (!submitting.value) void submitEntry()
}, 50)
}
const handleUserCodeInput = () => {
if (props.scanMode !== 'scanner' || !userCode.value.trim() || submitting.value) return
clearScannerSubmitTimer()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
handleUserCodeComplete()
}, 300)
}
const resetDashboardData = () => {
recentRecords.value = []
userStats.value = []
statistics.value = { today_total: 0, success_total: 0, failed_total: 0 }
}
const isSameSelection = (processId: string | number, stepId: string | number) => String(props.selectedProcess) === String(processId) && String(props.selectedStep) === String(stepId)
const resetUserCodeCheckState = () => { userCodeCheckError.value = '' }
const runUserCodeCheck = async (value: string, silent: boolean = false) => {
const normalizedValue = value.trim()
if (!normalizedValue) {
checkingUserCode.value = false
resetUserCodeCheckState()
return false
}
checkingUserCode.value = true
try {
await checkMemberUser(normalizedValue)
userCodeCheckError.value = ''
return true
} catch (error: any) {
userCodeCheckError.value = error?.message || '用户编码不存在'
if (!silent) ElMessage.warning(userCodeCheckError.value)
return false
} finally {
checkingUserCode.value = false
}
}
const scheduleUserCodeCheck = (value: string) => {
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
const normalizedValue = value.trim()
if (!normalizedValue) {
resetUserCodeCheckState()
checkingUserCode.value = false
return
}
userCodeCheckTimer = setTimeout(() => { void runUserCodeCheck(normalizedValue, true) }, 360)
}
const loadStatisticsData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
resetDashboardData()
return
}
const requestToken = ++statisticsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const params = { process_id: processSnapshot, step_id: stepSnapshot }
const [statsRes, userStatsRes] = await Promise.all([
getProductStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
getProductUserCodeStats(params).catch(() => []),
])
if (requestToken !== statisticsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
statistics.value = {
today_total: Number(statsRes?.today_total || 0),
success_total: Number(statsRes?.success_total || 0),
failed_total: Number(statsRes?.failed_total || 0),
}
userStats.value = Array.isArray(userStatsRes) ? userStatsRes : []
}
const loadRecentData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
recentRecords.value = []
return
}
const requestToken = ++recentRecordsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const res = await getProductRecentRecords({ process_id: processSnapshot, step_id: stepSnapshot, limit: 10 }).catch(() => ({ items: [] }))
if (requestToken !== recentRecordsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
recentRecords.value = Array.isArray(res?.items) ? res.items : []
}
const loadDataEntryConfig = async () => {
const requestToken = ++dataEntryConfigRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const processOrderNoSnapshot = processOrderNo.value.trim()
const productCodeSnapshot = productCode.value.trim()
const userCodeSnapshot = userCode.value.trim()
const res = await getDataEntryConfig({
process_id: processSnapshot,
step_id: stepSnapshot,
apply_mode: 'machine',
process_order_no: processOrderNoSnapshot,
product_code: productCodeSnapshot,
user_code: userCodeSnapshot,
order_no: processOrderNoSnapshot,
}).catch(() => null)
if (requestToken !== dataEntryConfigRequestToken || !isSameSelection(processSnapshot, stepSnapshot) || processOrderNo.value.trim() !== processOrderNoSnapshot || productCode.value.trim() !== productCodeSnapshot || userCode.value.trim() !== userCodeSnapshot) return
basicSettings.value = res?.basic_setting || {}
specificRules.value = Array.isArray(res?.specific_rules) ? res.specific_rules : []
}
const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<string, string>) => {
const matchField = rule.match_field || 'order_no'
const matchValue = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!matchValue || !pattern) {
return false
}
try {
return new RegExp(pattern).test(matchValue)
} catch {
return false
}
}
const validateSpecificRules = () => {
if (!specificRules.value.length) {
return true
}
const context = {
process_order_no: processOrderNo.value.trim(),
product_code: productCode.value.trim(),
user_code: userCode.value.trim(),
}
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
return false
}
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
})
if (blockedRule) {
ElMessage.warning(blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交')
const matchField = blockedRule.match_field || 'process_order_no'
if (matchField === 'product_code') {
focusProductCodeInput()
} else if (matchField === 'user_code') {
focusUserCodeInput()
} else {
focusPrimaryInput()
}
return false
}
return true
}
const validateBeforeSubmit = () => {
if (!props.selectedProcess) { ElMessage.warning('请选择工序'); return false }
if (!props.selectedStep) { ElMessage.warning('请选择步骤'); return false }
if (!processOrderNo.value.trim()) { ElMessage.warning('请输入加工单号'); focusPrimaryInput(); return false }
const processOrderNoRegex = String(basicSettings.value.process_order_no_regex || '').trim()
if (processOrderNoRegex) {
try {
const regex = new RegExp(processOrderNoRegex)
if (!regex.test(processOrderNo.value.trim())) { ElMessage.warning('加工单号格式不符合生产配置规则'); focusPrimaryInput(); return false }
} catch {}
}
if (!productCode.value.trim()) { ElMessage.warning('请输入商品编码'); focusProductCodeInput(); return false }
const productCodeMinLength = Number(basicSettings.value.product_code_min_length || 0)
if (productCodeMinLength > 0 && productCode.value.trim().length < productCodeMinLength) { ElMessage.warning(`商品编码长度不能少于 ${productCodeMinLength} 位`); focusProductCodeInput(); return false }
const productCodeMaxLength = Number(basicSettings.value.product_code_max_length || 0)
if (productCodeMaxLength > 0 && productCode.value.trim().length > productCodeMaxLength) { ElMessage.warning(`商品编码长度不能超过 ${productCodeMaxLength} 位`); focusProductCodeInput(); return false }
const productCodeRegex = String(basicSettings.value.product_code_regex || '').trim()
if (productCodeRegex) {
try {
const regex = new RegExp(productCodeRegex)
if (!regex.test(productCode.value.trim())) { ElMessage.warning('商品编码格式不符合生产配置规则'); focusProductCodeInput(); return false }
} catch {}
}
if (!Number.isFinite(Number(quantity.value)) || Number(quantity.value) < 1) { ElMessage.warning('数量必须大于 0'); focusQuantityInput(); return false }
if (!userCode.value.trim()) { ElMessage.warning('请输入用户编码'); focusUserCodeInput(); return false }
const userCodeRegex = String(basicSettings.value.user_code_regex || '').trim()
if (userCodeRegex) {
try {
const regex = new RegExp(userCodeRegex)
if (!regex.test(userCode.value.trim())) { ElMessage.warning('用户编码格式不符合基础设置规则'); focusUserCodeInput(); return false }
} catch {}
}
if (userCodeCheckError.value) { ElMessage.warning(userCodeCheckError.value); focusUserCodeInput(); return false }
if (!validateSpecificRules()) { return false }
return true
}
const resetForm = () => {
processOrderNo.value = ''
productCode.value = ''
quantity.value = 1
if (!autoUserCode.value) {
userCode.value = ''
resetUserCodeCheckState()
}
saveLocalState()
focusPrimaryInput()
}
const submitEntry = async () => {
if (!validateBeforeSubmit()) return
const passed = await runUserCodeCheck(userCode.value)
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
submitting.value = true
try {
const result = await submitProductRecord({
process_order_no: processOrderNo.value.trim(),
product_code: productCode.value.trim(),
quantity: Number(quantity.value) || 1,
process_id: props.selectedProcess,
step_id: props.selectedStep,
user_code: userCode.value.trim(),
scan_result: 1,
})
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
ElMessage.success('录入成功')
emit('play-success')
emit('speak-success')
saveLocalState()
resetForm()
await Promise.all([loadStatisticsData(), loadRecentData()])
} catch (error: any) {
ElMessage.error(error?.message || '录入失败')
emit('play-error')
emit('speak-error')
} finally {
submitting.value = false
}
}
const refreshBySelection = async () => {
if (!props.selectedProcess || !props.selectedStep) {
basicSettings.value = {}
specificRules.value = []
resetDashboardData()
return
}
resetDashboardData()
await Promise.all([loadStatisticsData(), loadRecentData(), loadDataEntryConfig()])
focusPrimaryInput()
}
watch(() => [props.selectedProcess, props.selectedStep], () => { if (initialized) void refreshBySelection() })
watch(processOrderNo, () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
watch([productCode, quantity], () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
watch(userCode, () => { if (initialized) { scheduleUserCodeCheck(userCode.value); if (autoUserCode.value || userCode.value.trim()) saveLocalState(); void loadDataEntryConfig() } })
watch([autoUserCode, hideProcessTotal], () => { if (initialized) saveLocalState() })
onMounted(async () => {
restoreLocalState()
await refreshBySelection()
initialized = true
})
onBeforeUnmount(() => {
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
clearScannerSubmitTimer()
})
defineExpose({ focusPrimaryInput, focusProductCodeInput, focusQuantityInput, focusUserCodeInput, submitEntry, resetForm })
</script>
<style scoped src="./entry-panel.css"></style>
@@ -0,0 +1,610 @@
<template>
<div class="entry-mode-panel">
<div class="main-top-grid">
<section class="workbench-panel entry-column-panel">
<div class="panel-header column-header">
<div>
<div class="panel-title">订单扫码输入</div>
<div class="panel-subtitle">当前工序:{{ processLabel || '未选择' }} / {{ stepLabel || '未选择' }}</div>
</div>
<div class="panel-metric-card">
<span class="panel-metric-label">总数</span>
<strong class="panel-metric-value">{{ hideProcessTotal ? '***' : statistics.today_total }}</strong>
<button type="button" class="metric-eye-button" :class="{ 'is-hidden': hideProcessTotal }" @click="hideProcessTotal = !hideProcessTotal">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5C6.5 5 2.12 8.44 1 12c1.12 3.56 5.5 7 11 7s9.88-3.44 11-7c-1.12-3.56-5.5-7-11-7Zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9Zm0-2.5A2 2 0 1 0 12 10a2 2 0 0 0 0 4Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
<div class="workbench-form">
<div class="field-group field-primary">
<label>订单号</label>
<el-input
ref="orderNoInputRef"
v-model="orderNo"
clearable
size="large"
placeholder="请扫描或输入订单号"
@keyup.enter="handleOrderNoComplete"
/>
</div>
<div class="field-row">
<div class="field-group">
<div class="field-label-row">
<label>用户编码</label>
<div class="lock-switch">
<span>锁定用户编码</span>
<el-switch v-model="autoUserCode" />
</div>
</div>
<el-input
ref="userCodeInputRef"
v-model="userCode"
:disabled="autoUserCode"
clearable
size="large"
placeholder="请扫描或输入用户编码"
@update:model-value="userCode = String($event || '').toUpperCase()"
@input="handleUserCodeInput"
@keyup.enter="handleUserCodeComplete"
@blur="runUserCodeCheck(userCode, true)"
/>
<div v-if="userCodeCheckError" class="field-error-tip">{{ userCodeCheckError }}</div>
</div>
</div>
<div class="action-row">
<el-button type="primary" size="large" :loading="submitting" @click="submitEntry">提交</el-button>
<el-button size="large" @click="resetForm">重置输入</el-button>
</div>
</div>
</section>
<section class="workbench-panel stats-column-panel">
<div class="dashboard-header column-header">
<div class="dashboard-title">工位统计</div>
<span class="dashboard-caption">当日扫码量</span>
</div>
<div v-if="userStats.length" class="station-grid">
<div v-for="item in userStats" :key="item.user_code" class="station-card">
<div class="station-icon">工</div>
<div class="station-info">
<strong>{{ item.user_code }}</strong>
</div>
<b>{{ item.count }}</b>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="72" />
</section>
</div>
<section class="records-panel">
<div class="dashboard-header records-header">
<div class="dashboard-title">最近记录</div>
<span class="dashboard-caption">实时刷新最近 10 条</span>
</div>
<el-table :data="recentRecords" stripe>
<el-table-column prop="order_no" label="订单号" min-width="180" />
<el-table-column prop="user_code" label="用户编码" min-width="120" />
<el-table-column label="工序" min-width="140">
<template #default="scope">{{ scope.row.process?.process_name || '-' }}</template>
</el-table-column>
<el-table-column label="步骤" min-width="140">
<template #default="scope">{{ scope.row.step?.step_name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="120">
<template #default="scope">{{ scope.row.status?.status_name || '-' }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="scope">
<el-tag :type="Number(scope.row.scan_result) === 1 ? 'success' : 'danger'">
{{ Number(scope.row.scan_result) === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="120" />
<el-table-column prop="scan_date" label="扫码时间" min-width="180" />
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
checkMemberUser,
getDataEntryConfig,
getRecentRecords,
getStatistics,
getUserCodeStats,
submitScanData,
type ProduceRecordItem,
type ProduceDataEntrySpecificRule,
type ProduceStatistics,
type ProduceUserStatsItem,
} from '~/apps/produce/api/data-entry'
interface Props {
selectedProcess?: string | number
selectedStep?: string | number
processLabel?: string
stepLabel?: string
scanMode?: 'manual' | 'scanner'
}
const props = withDefaults(defineProps<Props>(), {
selectedProcess: '',
selectedStep: '',
processLabel: '',
stepLabel: '',
scanMode: 'manual',
})
const emit = defineEmits<{
'play-success': []
'play-error': []
'speak-success': []
'speak-error': []
}>()
const STORAGE_KEY_LOCK = 'produce_web_order_entry_lock'
const STORAGE_KEY_USER_CODE = 'produce_web_order_entry_user_code'
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_order_entry_hide_process_total'
const orderNo = ref('')
const userCode = ref('')
const autoUserCode = ref(false)
const hideProcessTotal = ref(false)
const submitting = ref(false)
const recentRecords = ref<ProduceRecordItem[]>([])
const userStats = ref<ProduceUserStatsItem[]>([])
const statistics = ref<ProduceStatistics>({
today_total: 0,
success_total: 0,
failed_total: 0,
})
const basicSettings = ref<Record<string, any>>({})
const specificRules = ref<ProduceDataEntrySpecificRule[]>([])
const checkingUserCode = ref(false)
const userCodeCheckError = ref('')
const orderNoInputRef = ref()
const userCodeInputRef = ref()
let userCodeCheckTimer: ReturnType<typeof setTimeout> | null = null
let scannerSubmitTimer: ReturnType<typeof setTimeout> | null = null
let statisticsRequestToken = 0
let recentRecordsRequestToken = 0
let dataEntryConfigRequestToken = 0
let initialized = false
const restoreLocalState = () => {
if (typeof window === 'undefined') return
autoUserCode.value = localStorage.getItem(STORAGE_KEY_LOCK) === 'true'
hideProcessTotal.value = localStorage.getItem(STORAGE_KEY_HIDE_PROCESS_TOTAL) === 'true'
if (autoUserCode.value) {
userCode.value = localStorage.getItem(STORAGE_KEY_USER_CODE) || ''
}
}
const saveLocalState = () => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY_LOCK, String(autoUserCode.value))
localStorage.setItem(STORAGE_KEY_HIDE_PROCESS_TOTAL, String(hideProcessTotal.value))
if (autoUserCode.value && userCode.value.trim()) {
localStorage.setItem(STORAGE_KEY_USER_CODE, userCode.value)
} else {
localStorage.removeItem(STORAGE_KEY_USER_CODE)
}
}
const focusPrimaryInput = () => {
setTimeout(() => orderNoInputRef.value?.focus?.(), 80)
}
const focusUserCodeInput = () => {
setTimeout(() => userCodeInputRef.value?.focus?.(), 80)
}
const clearScannerSubmitTimer = () => {
if (!scannerSubmitTimer) {
return
}
clearTimeout(scannerSubmitTimer)
scannerSubmitTimer = null
}
const handleOrderNoComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (submitting.value) {
return
}
if (autoUserCode.value) {
void submitEntry()
return
}
focusUserCodeInput()
}
const handleUserCodeComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (submitting.value) {
return
}
clearScannerSubmitTimer()
userCodeInputRef.value?.blur?.()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
if (!submitting.value) {
void submitEntry()
}
}, 50)
}
const handleUserCodeInput = () => {
if (props.scanMode !== 'scanner' || !userCode.value.trim() || submitting.value) {
return
}
clearScannerSubmitTimer()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
handleUserCodeComplete()
}, 300)
}
const resetDashboardData = () => {
recentRecords.value = []
userStats.value = []
statistics.value = {
today_total: 0,
success_total: 0,
failed_total: 0,
}
}
const isSameSelection = (processId: string | number, stepId: string | number) => {
return String(props.selectedProcess) === String(processId)
&& String(props.selectedStep) === String(stepId)
}
const resetUserCodeCheckState = () => {
userCodeCheckError.value = ''
}
const runUserCodeCheck = async (value: string, silent: boolean = false) => {
const normalizedValue = value.trim()
if (!normalizedValue) {
checkingUserCode.value = false
resetUserCodeCheckState()
return false
}
checkingUserCode.value = true
try {
await checkMemberUser(normalizedValue)
userCodeCheckError.value = ''
return true
} catch (error: any) {
userCodeCheckError.value = error?.message || '用户编码不存在'
if (!silent) {
ElMessage.warning(userCodeCheckError.value)
}
return false
} finally {
checkingUserCode.value = false
}
}
const scheduleUserCodeCheck = (value: string) => {
if (userCodeCheckTimer) {
clearTimeout(userCodeCheckTimer)
}
const normalizedValue = value.trim()
if (!normalizedValue) {
resetUserCodeCheckState()
checkingUserCode.value = false
return
}
userCodeCheckTimer = setTimeout(() => {
void runUserCodeCheck(normalizedValue, true)
}, 360)
}
const loadStatisticsData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
resetDashboardData()
return
}
const requestToken = ++statisticsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const params = {
process_id: processSnapshot,
step_id: stepSnapshot,
}
const [statsRes, userStatsRes] = await Promise.all([
getStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
getUserCodeStats(params).catch(() => []),
])
if (requestToken !== statisticsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) {
return
}
statistics.value = {
today_total: Number(statsRes?.today_total || 0),
success_total: Number(statsRes?.success_total || 0),
failed_total: Number(statsRes?.failed_total || 0),
}
userStats.value = Array.isArray(userStatsRes) ? userStatsRes : []
}
const loadRecentData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
recentRecords.value = []
return
}
const requestToken = ++recentRecordsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const res = await getRecentRecords({
process_id: processSnapshot,
step_id: stepSnapshot,
limit: 10,
}).catch(() => ({ items: [] }))
if (requestToken !== recentRecordsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) {
return
}
recentRecords.value = Array.isArray(res?.items) ? res.items : []
}
const loadDataEntryConfig = async () => {
const requestToken = ++dataEntryConfigRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const orderSnapshot = orderNo.value.trim()
const res = await getDataEntryConfig({
process_id: processSnapshot,
step_id: stepSnapshot,
apply_mode: 'order',
order_no: orderSnapshot,
}).catch(() => null)
if (
requestToken !== dataEntryConfigRequestToken
|| !isSameSelection(processSnapshot, stepSnapshot)
|| orderNo.value.trim() !== orderSnapshot
) {
return
}
basicSettings.value = res?.basic_setting || {}
specificRules.value = Array.isArray(res?.specific_rules) ? res.specific_rules : []
}
const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<string, string>) => {
const matchField = rule.match_field || 'order_no'
const matchValue = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!matchValue || !pattern) {
return false
}
try {
return new RegExp(pattern).test(matchValue)
} catch {
return false
}
}
const validateSpecificRules = () => {
if (!specificRules.value.length) {
return true
}
const context = {
order_no: orderNo.value.trim(),
user_code: userCode.value.trim(),
}
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
if ((rule.apply_mode || 'order') !== 'order' && rule.apply_mode !== 'both') {
return false
}
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
})
if (blockedRule) {
ElMessage.warning(blockedRule.rule_name || '当前订单命中特定规则,禁止提交')
focusPrimaryInput()
return false
}
return true
}
const validateBeforeSubmit = () => {
if (!props.selectedProcess) {
ElMessage.warning('请选择工序')
return false
}
if (!props.selectedStep) {
ElMessage.warning('请选择步骤')
return false
}
if (!orderNo.value.trim()) {
ElMessage.warning('请输入订单号')
focusPrimaryInput()
return false
}
const orderNoMinLength = Number(basicSettings.value.order_no_min_length || 0)
if (orderNoMinLength > 0 && orderNo.value.trim().length < orderNoMinLength) {
ElMessage.warning(`订单号长度不能少于 ${orderNoMinLength} 位`)
focusPrimaryInput()
return false
}
const orderNoRegex = String(basicSettings.value.order_no_regex || '').trim()
if (orderNoRegex) {
try {
const regex = new RegExp(orderNoRegex)
if (!regex.test(orderNo.value.trim())) {
ElMessage.warning('订单号格式不符合基础设置规则')
focusPrimaryInput()
return false
}
} catch {
}
}
if (!userCode.value.trim()) {
ElMessage.warning('请输入用户编码')
focusUserCodeInput()
return false
}
const userCodeRegex = String(basicSettings.value.user_code_regex || '').trim()
if (userCodeRegex) {
try {
const regex = new RegExp(userCodeRegex)
if (!regex.test(userCode.value.trim())) {
ElMessage.warning('用户编码格式不符合基础设置规则')
focusUserCodeInput()
return false
}
} catch {
}
}
if (userCodeCheckError.value) {
ElMessage.warning(userCodeCheckError.value)
focusUserCodeInput()
return false
}
if (!validateSpecificRules()) {
return false
}
return true
}
const resetForm = () => {
orderNo.value = ''
if (!autoUserCode.value) {
userCode.value = ''
resetUserCodeCheckState()
}
saveLocalState()
focusPrimaryInput()
}
const submitEntry = async () => {
if (!validateBeforeSubmit()) return
const passed = await runUserCodeCheck(userCode.value)
if (!passed) {
emit('play-error')
nextTick(() => focusUserCodeInput())
return
}
submitting.value = true
try {
const result = await submitScanData({
order_no: orderNo.value.trim(),
process_id: props.selectedProcess,
step_id: props.selectedStep,
user_code: userCode.value.trim(),
scan_result: 1,
})
if (Number(result?.code) !== 0) {
throw new Error(result?.msg || '录入失败')
}
ElMessage.success('录入成功')
emit('play-success')
emit('speak-success')
saveLocalState()
resetForm()
await Promise.all([loadStatisticsData(), loadRecentData()])
} catch (error: any) {
ElMessage.error(error?.message || '录入失败')
emit('play-error')
emit('speak-error')
} finally {
submitting.value = false
}
}
const refreshBySelection = async () => {
if (!props.selectedProcess || !props.selectedStep) {
basicSettings.value = {}
specificRules.value = []
resetDashboardData()
return
}
resetDashboardData()
await Promise.all([loadStatisticsData(), loadRecentData(), loadDataEntryConfig()])
focusPrimaryInput()
}
watch(
() => [props.selectedProcess, props.selectedStep],
() => {
if (!initialized) return
void refreshBySelection()
}
)
watch(orderNo, () => {
if (!initialized) return
saveLocalState()
void loadDataEntryConfig()
})
watch(userCode, () => {
if (!initialized) return
scheduleUserCodeCheck(userCode.value)
if (autoUserCode.value || userCode.value.trim()) {
saveLocalState()
}
})
watch([autoUserCode, hideProcessTotal], () => {
if (!initialized) return
saveLocalState()
})
onMounted(async () => {
restoreLocalState()
await refreshBySelection()
initialized = true
})
onBeforeUnmount(() => {
if (userCodeCheckTimer) {
clearTimeout(userCodeCheckTimer)
}
clearScannerSubmitTimer()
})
defineExpose({
focusPrimaryInput,
focusUserCodeInput,
submitEntry,
resetForm,
})
</script>
<style scoped src="./entry-panel.css"></style>
@@ -0,0 +1,340 @@
.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__inner),
.field-row :deep(.el-input__inner),
.field-row :deep(.el-input-number .el-input__inner) {
font-size: 18px;
}
.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%;
}
.quantity-input :deep(.el-input__wrapper) {
padding-right: 40px;
overflow: hidden;
}
.quantity-input :deep(.el-input-number__increase),
.quantity-input :deep(.el-input-number__decrease) {
right: 1px;
width: 36px;
margin: 0;
background: #f8fafc;
color: #94a3b8;
border-left: 1px solid #dbe4f0;
box-shadow: none;
}
.quantity-input :deep(.el-input-number__increase) {
top: 1px;
height: calc(50% - 0.5px);
border-top-right-radius: 13px;
border-bottom: 1px solid #e5edf6;
}
.quantity-input :deep(.el-input-number__decrease) {
bottom: 1px;
height: calc(50% - 0.5px);
border-bottom-right-radius: 13px;
}
.quantity-input :deep(.el-input-number__increase:hover),
.quantity-input :deep(.el-input-number__decrease:hover) {
color: var(--el-color-primary);
background: #f1f5f9;
}
.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 :deep(.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) {
.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) {
.workbench-panel,
.records-panel {
padding: 18px;
border-radius: 16px;
}
.action-row {
flex-wrap: wrap;
}
.action-row :deep(.el-button) {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
<template>
<div class="produce-work-page exception-page">
<div class="page-shell">
<section class="page-hero exception-hero">
<div>
<span class="eyebrow">现场提报</span>
<h1>异常处理</h1>
<p>先校验订单,再选择异常类型与原因,补充现场信息后提交。</p>
</div>
<div class="hero-user">
<strong>{{ currentUser.name || '未登录' }}</strong>
<span>{{ currentUser.code || '请先登录' }}</span>
</div>
</section>
<section class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-card">
<strong :class="item.type">{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</section>
<div class="content-grid">
<main class="main-column">
<section class="panel">
<div class="panel-title"><span>1</span><div><h2>扫描或录入单号</h2><p>支持订单号、整机编码或设备条码</p></div></div>
<div class="inline-form">
<el-input v-model="form.orderNo" size="large" clearable placeholder="请输入单号 / 条码" @keyup.enter="checkOrder" />
<el-button type="primary" size="large" :loading="checking" @click="checkOrder">校验单号</el-button>
</div>
<div v-if="orderMessage" class="validation" :class="orderValid ? 'success' : 'error'">{{ orderMessage }}</div>
</section>
<section class="panel">
<div class="panel-title"><span>2</span><div><h2>选择处理类型</h2><p>根据现场异常选择对应的处理分类</p></div><em>必选</em></div>
<div class="option-grid type-grid">
<button v-for="item in typeOptions" :key="item.value" type="button" :class="['choice-card', { active: form.type === item.value }]" @click="selectType(item.value)">
<strong>{{ item.label }}</strong><small>{{ item.desc || '按异常设置执行处理' }}</small>
</button>
</div>
<div class="sub-title"><h2>3. 选择异常原因</h2><span>快速点选</span></div>
<div class="option-grid reason-grid">
<button v-for="item in activeReasons" :key="item.value" type="button" :class="['reason-card', { active: form.reason === item.value }]" @click="selectReason(item.value)">{{ item.label }}</button>
</div>
<div v-if="activeIssues.length" class="issue-box">
<div class="sub-title"><h2>具体问题</h2><span>{{ activeReasons.find(item => item.value === form.reason)?.label }}</span></div>
<el-select v-model="form.issue" filterable clearable class="full-width" placeholder="请选择具体问题">
<el-option v-for="item in activeIssues" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</section>
<section class="panel">
<div class="panel-title"><span>4</span><div><h2>现场取证</h2><p>最多上传 4 张现场照片</p></div><em class="optional">选填</em></div>
<el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed">
<span class="upload-plus">+</span>
</el-upload>
<el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="补充现场情况、处理建议、责任工位等" />
</section>
<div class="form-actions">
<el-button size="large" @click="resetForm">重置</el-button>
<el-button type="primary" size="large" :loading="submitting" @click="submit">提交异常</el-button>
</div>
</main>
<aside class="side-column">
<section class="panel operator-panel"><h2>提报信息</h2><div><span>提报人</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>提交时间</span><strong>{{ nowText }}</strong></div></section>
<section class="panel"><div class="side-title"><h2>最近异常</h2><span>现场回看</span></div><el-empty v-if="!recentExceptions.length" description="暂无异常记录" :image-size="70" /><div v-for="item in recentExceptions" :key="item.id" class="recent-item"><div><strong>{{ item.order_no || '-' }}</strong><p>{{ item.type_name || '-' }} · {{ [item.reason_name, item.issue_name].filter(Boolean).join(' · ') }}</p></div><el-tag :type="item.status === 2 ? 'success' : item.status === 1 ? 'warning' : 'danger'">{{ item.status_name || '待处理' }}</el-tag></div></section>
</aside>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { UploadUserFile } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useMemberStore } from '~/stores/member'
import { checkExceptionOrder, getExceptionSettings, getExceptionStatistics, getRecentExceptions, submitExceptionRecord, uploadProduceImage, type ProduceExceptionRecord, type ProduceExceptionSetting } from '~/apps/produce/api/exception'
const memberStore = useMemberStore()
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
const form = ref({ orderNo: '', type: '', reason: '', issue: '', remark: '' })
const settings = ref<ProduceExceptionSetting[]>([])
const recentExceptions = ref<ProduceExceptionRecord[]>([])
const summary = ref({ pending: 0, review: 0, done: 0 })
const imageFiles = ref<UploadUserFile[]>([])
const checking = ref(false)
const submitting = ref(false)
const orderValid = ref(false)
const orderMessage = ref('')
const nowText = ref('')
const typeOptions = computed(() => settings.value.filter(item => item.setting_type === 'type').map(toOption))
const activeReasons = computed(() => settings.value.filter(item => item.setting_type === 'reason' && String(item.parent_id || '') === String(form.value.type)).map(toOption))
const activeIssues = computed(() => settings.value.filter(item => item.setting_type === 'issue' && String(item.parent_id || '') === String(form.value.reason)).map(toOption))
const summaryItems = computed(() => [{ label: '待处理', value: summary.value.pending, type: 'danger' }, { label: '待复核', value: summary.value.review, type: 'warning' }, { label: '已处理', value: summary.value.done, type: 'success' }])
const toOption = (item: ProduceExceptionSetting) => ({ value: String(item.id), label: item.name, desc: item.remark || '' })
const updateNow = () => { nowText.value = new Date().toLocaleString('zh-CN', { hour12: false }) }
const loadData = async () => {
try {
const [settingData, statisticData, recentData] = await Promise.all([getExceptionSettings(), getExceptionStatistics({ user_code: currentUser.value.code }), getRecentExceptions({ user_code: currentUser.value.code, limit: 5 })])
settings.value = Array.isArray(settingData) ? settingData : []
summary.value = statisticData || summary.value
recentExceptions.value = Array.isArray(recentData) ? recentData : []
if (!form.value.type) selectType(typeOptions.value[0]?.value || '')
} catch { /* 请求层已提示错误 */ }
}
const selectType = (value: string) => { form.value.type = value; form.value.reason = activeReasons.value[0]?.value || ''; form.value.issue = activeIssues.value[0]?.value || '' }
const selectReason = (value: string) => { form.value.reason = value; form.value.issue = activeIssues.value[0]?.value || '' }
const checkOrder = async () => {
const orderNo = form.value.orderNo.trim()
if (!orderNo) { orderMessage.value = '请输入单号或条码'; orderValid.value = false; return }
checking.value = true
try { const result = await checkExceptionOrder(orderNo); orderValid.value = true; orderMessage.value = `已匹配${result?.reference_name || '记录'}` } catch { orderValid.value = false; orderMessage.value = '未找到对应订单或整机记录' } finally { checking.value = false }
}
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
const resetForm = () => { form.value = { orderNo: '', type: typeOptions.value[0]?.value || '', reason: '', issue: '', remark: '' }; imageFiles.value = []; orderValid.value = false; orderMessage.value = ''; selectType(form.value.type); updateNow() }
const submit = async () => {
if (!orderValid.value || !form.value.orderNo.trim()) { await checkOrder(); if (!orderValid.value) return }
if (!form.value.type || !form.value.reason || (activeIssues.value.length && !form.value.issue)) { ElMessage.warning('请选择处理类型、异常原因和具体问题'); return }
if (!currentUser.value.code) { ElMessage.warning('请先登录后再提报异常'); return }
submitting.value = true
try {
const images: string[] = []
for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'exception'); if (url) images.push(url) }
const response = await submitExceptionRecord({ order_no: form.value.orderNo.trim(), user_code: currentUser.value.code, type_id: form.value.type, reason_id: form.value.reason, issue_id: form.value.issue || '', images, remark: form.value.remark.trim() })
if (response?.code !== 0) { ElMessage.error(response?.msg || '异常提交失败'); return }
ElMessage.success('异常已提交'); resetForm(); await loadData()
} finally { submitting.value = false }
}
onMounted(() => { updateNow(); void loadData() })
</script>
<style scoped>
.produce-work-page { min-height: calc(100vh - 60px); padding: 28px; background: #f5f7fb; color: #17233d; }
.page-shell { max-width: 1440px; margin: 0 auto; }
.page-hero { display:flex; align-items:center; justify-content:space-between; gap:24px; padding:32px 36px; border-radius:22px; color:#fff; box-shadow:0 14px 32px rgba(37,99,235,.12); }
.exception-hero { background:linear-gradient(135deg,#ff9d42,#ef625f); }
.eyebrow { font-size:13px; opacity:.85; } h1 { margin:8px 0 0; font-size:30px; } .page-hero p { margin:8px 0 0; opacity:.86; }
.hero-user { min-width:150px; padding:16px 20px; border-radius:14px; background:rgba(255,255,255,.16); text-align:right; } .hero-user strong,.hero-user span { display:block; } .hero-user span { margin-top:6px; font-size:13px; opacity:.8; }
.summary-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:20px 0; } .summary-card,.panel { background:#fff; border:1px solid #e7edf5; border-radius:18px; box-shadow:0 8px 24px rgba(36,59,92,.04); } .summary-card { padding:18px 22px; } .summary-card strong,.summary-card span { display:block; } .summary-card strong { font-size:28px; } .summary-card span { margin-top:6px; color:#8a94a6; font-size:13px; } .danger { color:#ef625f; } .warning { color:#e59a2f; } .success { color:#26b36c; }
.content-grid { display:grid; grid-template-columns:minmax(0,1fr) 350px; gap:20px; align-items:start; } .main-column,.side-column { display:flex; flex-direction:column; gap:20px; } .panel { padding:26px; } .panel-title,.side-title,.sub-title { display:flex; align-items:flex-start; gap:14px; } .panel-title > span { display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:#fff0e5; color:#e97020; font-weight:700; flex:none; } h2 { margin:0; font-size:17px; } .panel-title p { margin:6px 0 0; color:#9aa4b4; font-size:13px; } .panel-title em { margin-left:auto; padding:5px 10px; border-radius:999px; color:#e97020; background:#fff3e8; font-size:12px; font-style:normal; } .panel-title em.optional { color:#8793a6; background:#f3f5f8; }
.inline-form { display:flex; gap:12px; margin-top:20px; } .inline-form .el-input { flex:1; } .validation { margin-top:10px; font-size:13px; } .validation.success { color:#26b36c; } .validation.error { color:#ef625f; } .option-grid { display:grid; gap:12px; margin-top:18px; } .type-grid { grid-template-columns:repeat(2,1fr); } button { font:inherit; cursor:pointer; } .choice-card,.reason-card { border:1px solid #e6edf5; border-radius:14px; background:#f8fafc; text-align:left; transition:.2s; } .choice-card { padding:16px; } .choice-card strong,.choice-card small { display:block; } .choice-card strong { color:#2b3853; } .choice-card small { margin-top:8px; color:#9aa4b4; } .choice-card.active,.reason-card.active { border-color:#ff9a3c; background:#fff5ed; color:#e97020; } .sub-title { align-items:center; justify-content:space-between; margin-top:26px; } .sub-title span,.side-title span { color:#9aa4b4; font-size:12px; } .reason-grid { grid-template-columns:repeat(3,1fr); } .reason-card { min-height:46px; padding:8px; text-align:center; color:#56647a; } .issue-box { margin-top:18px; padding:16px; border-radius:14px; background:#fffaf5; } .issue-box .sub-title { margin:0 0 12px; } .full-width { width:100%; }
:deep(.el-upload--picture-card),:deep(.el-upload-list__item) { width:92px; height:92px; } .upload-plus { font-size:30px; color:#ff9a3c; } .remark-input { margin-top:18px; } .form-actions { display:flex; justify-content:flex-end; gap:12px; } .operator-panel h2 { margin-bottom:18px; } .operator-panel div { display:flex; justify-content:space-between; padding:13px 0; border-bottom:1px solid #eef2f7; font-size:13px; } .operator-panel div:last-child { border-bottom:0; } .operator-panel span { color:#9aa4b4; } .side-title { align-items:center; justify-content:space-between; } .recent-item { display:flex; justify-content:space-between; gap:10px; padding:16px 0; border-bottom:1px solid #eef2f7; } .recent-item:last-child { border-bottom:0; } .recent-item strong { font-size:14px; } .recent-item p { margin:6px 0 0; color:#8a94a6; font-size:12px; }
@media (max-width: 900px) { .produce-work-page { padding:16px; } .content-grid { grid-template-columns:1fr; } .side-column { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 640px) { .page-hero { padding:24px; align-items:flex-start; flex-direction:column; } .hero-user { align-self:stretch; text-align:left; } .summary-grid,.side-column { grid-template-columns:1fr; } .type-grid,.reason-grid { grid-template-columns:1fr; } .inline-form { flex-direction:column; } .form-actions { justify-content:stretch; } .form-actions .el-button { flex:1; } }
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="produce-query-page">
<div class="page-shell">
<section class="page-hero">
<div>
<span class="eyebrow">生产追溯中心</span>
<h1>综合查询工作台</h1>
<p>按订单号查看生产、异常和维修全过程;也可以独立查询各类业务记录。</p>
</div>
<div class="hero-badge"><strong>{{ timeline.total }}</strong><span>订单时间线记录</span></div>
</section>
<section class="panel timeline-panel">
<div class="panel-heading"><div><h2>订单全流程时间线</h2><p>订单号查询会汇总扫码、整机、异常和维修记录,并按发生时间排序。</p></div></div>
<div class="search-row">
<el-input v-model="timelineOrderNo" size="large" clearable placeholder="请输入订单号" @keyup.enter="loadTimeline">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" size="large" :loading="timelineLoading" @click="loadTimeline">查询时间线</el-button>
</div>
<el-alert v-if="timelineMessage" :title="timelineMessage" :type="timeline.total ? 'success' : 'info'" :closable="false" class="timeline-alert" />
<el-empty v-if="timelineLoaded && !timelineLoading && !timeline.items.length" description="该订单暂无相关记录" :image-size="90" />
<div v-else-if="timeline.items.length" class="timeline-list">
<el-table :data="timeline.items" stripe class="timeline-table" empty-text="暂无时间线记录">
<el-table-column prop="event_date" label="发生时间" width="170" />
<el-table-column label="记录类型" width="112">
<template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template>
</el-table-column>
<el-table-column label="订单 / 产品" min-width="220" show-overflow-tooltip>
<template #default="scope">
<div class="timeline-content-cell">
<span class="content-mark" aria-hidden="true" />
<div class="timeline-main-text">
<strong>{{ scope.row.order_no || timeline.order_no || '-' }}</strong>
<small v-if="scope.row.product_code">产品:{{ scope.row.product_code }}</small>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip>
<template #default="scope">
<div class="timeline-node-cell">
<strong>{{ scope.row.process_name || '未指定工序' }}</strong>
<small v-if="scope.row.step_name">工位:{{ scope.row.step_name }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态 / 结果" min-width="155" show-overflow-tooltip>
<template #default="scope">
<span class="timeline-status">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '已记录' }}</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="78" align="right">
<template #default="scope">{{ scope.row.quantity ?? '—' }}</template>
</el-table-column>
<el-table-column label="业务详情" min-width="280" show-overflow-tooltip>
<template #default="scope">
<div v-if="scope.row.record_type === 'exception'" class="timeline-detail-cell">
<span><b>异常原因</b>{{ scope.row.exception_reason || '未填写' }}</span>
<span><b>具体问题</b>{{ scope.row.exception_content || '未填写' }}</span>
</div>
<div v-else-if="scope.row.record_type === 'repair'" class="timeline-detail-cell">
<span><b>物料分类</b>{{ scope.row.material_category || '未填写' }}</span>
<span><b>故障描述</b>{{ scope.row.fault_description || '未填写' }}</span>
</div>
<span v-else class="muted-placeholder">—</span>
</template>
</el-table-column>
<el-table-column label="操作人" min-width="130" show-overflow-tooltip>
<template #default="scope">
<div class="timeline-operator-cell">
<span>{{ scope.row.operator || '未记录' }}</span>
<small v-if="scope.row.user_code">工号:{{ scope.row.user_code }}</small>
</div>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
</el-table>
</div>
</section>
<section class="panel independent-panel">
<div class="panel-heading"><div><h2>独立记录查询</h2><p>切换记录类型,按条件分页查询。</p></div><el-button @click="resetIndependent">重置</el-button></div>
<el-tabs v-model="filters.record_type" class="query-tabs" @tab-change="handleRecordTypeChange">
<el-tab-pane label="扫码记录" name="scan" />
<el-tab-pane label="整机记录" name="product" />
<el-tab-pane label="异常记录" name="exception" />
<el-tab-pane label="维修记录" name="repair" />
</el-tabs>
<el-form :inline="true" class="filter-form" @submit.prevent="loadRecords">
<el-form-item label="关键词"><el-input v-model="filters.keyword" clearable :placeholder="keywordPlaceholder" /></el-form-item>
<el-form-item label="订单号"><el-input v-model="filters.order_no" clearable placeholder="精确订单号" /></el-form-item>
<el-form-item v-if="filters.record_type !== 'repair'" label="用户工号"><el-input v-model="filters.user_code" clearable placeholder="用户工号" /></el-form-item>
<el-form-item label="日期范围"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" :loading="recordLoading" @click="loadRecords">查询</el-button></el-form-item>
</el-form>
<el-table v-loading="recordLoading" :data="recordPage.items" stripe class="record-table" empty-text="暂无查询记录" @row-dblclick="showRecordDetail">
<el-table-column prop="event_date" label="发生时间" width="170" />
<el-table-column prop="record_type_name" label="记录类型" width="112"><template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template></el-table-column>
<el-table-column prop="order_no" label="订单号" min-width="160" show-overflow-tooltip />
<el-table-column prop="product_code" label="产品编码" min-width="145" show-overflow-tooltip />
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip><template #default="scope">{{ [scope.row.process_name, scope.row.step_name].filter(Boolean).join(' / ') || '—' }}</template></el-table-column>
<el-table-column label="状态 / 结果" min-width="145" show-overflow-tooltip><template #default="scope">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="操作人" min-width="125" show-overflow-tooltip><template #default="scope">{{ scope.row.operator || scope.row.user_code || '未记录' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="190" show-overflow-tooltip />
</el-table>
<div class="pagination-row"><span>共 {{ recordPage.total }} 条</span><el-pagination v-model:current-page="recordPage.page" v-model:page-size="recordPage.limit" layout="prev, pager, next, sizes" :page-sizes="[20, 50, 100]" :total="recordPage.total" @current-change="loadRecords" @size-change="loadRecords" /></div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { queryProduceRecords, queryProduceTimeline, type ProduceQueryRecord, type ProduceQueryRecordType } from '~/apps/produce/api/query'
const timelineOrderNo = ref('')
const timelineLoading = ref(false)
const timelineLoaded = ref(false)
const timelineMessage = ref('')
const timeline = reactive<{ order_no: string; total: number; items: ProduceQueryRecord[] }>({ order_no: '', total: 0, items: [] })
const filters = reactive<{ record_type: ProduceQueryRecordType; keyword: string; order_no: string; user_code: string; start_date?: string; end_date?: string }>({ record_type: 'scan', keyword: '', order_no: '', user_code: '' })
const dateRange = ref<string[]>([])
const recordLoading = ref(false)
const recordPage = reactive({ items: [] as ProduceQueryRecord[], total: 0, page: 1, limit: 20 })
const tagTypes: Record<ProduceQueryRecordType, 'primary' | 'success' | 'danger' | 'warning'> = { scan: 'primary', product: 'success', exception: 'danger', repair: 'warning' }
const tagType = (type: ProduceQueryRecordType) => tagTypes[type]
const keywordPlaceholder = computed(() => filters.record_type === 'repair' ? '维修单、产品、人员或备注' : '订单、产品、人员或备注')
const handleRecordTypeChange = () => { recordPage.page = 1; void loadRecords() }
const showRecordDetail = (row: ProduceQueryRecord) => { ElMessage.info(`${row.record_type_name}详情功能待接入`) }
const loadTimeline = async () => { const orderNo = timelineOrderNo.value.trim(); if (!orderNo) return ElMessage.warning('请输入订单号'); timelineLoading.value = true; timelineLoaded.value = false; timelineMessage.value = ''; try { const data = await queryProduceTimeline(orderNo); timeline.order_no = data?.order_no || orderNo; timeline.total = Number(data?.total || 0); timeline.items = Array.isArray(data?.items) ? data.items : []; timelineMessage.value = timeline.total ? `已找到 ${timeline.total} 条全流程记录` : '该订单暂无相关记录'; timelineLoaded.value = true } catch { timeline.order_no = orderNo; timeline.total = 0; timeline.items = []; timelineMessage.value = '订单查询失败,请检查订单号后重试'; timelineLoaded.value = true } finally { timelineLoading.value = false } }
const loadRecords = async () => { recordLoading.value = true; try { const data = await queryProduceRecords({ ...filters, ...(dateRange.value.length ? { start_date: dateRange.value[0], end_date: dateRange.value[1] } : {}), page: recordPage.page, limit: recordPage.limit }); recordPage.items = Array.isArray(data?.items) ? data.items : []; recordPage.total = Number(data?.total || 0) } catch { recordPage.items = []; recordPage.total = 0; ElMessage.error('记录查询失败,请稍后重试') } finally { recordLoading.value = false } }
const resetIndependent = () => { filters.record_type = 'scan'; filters.keyword = ''; filters.order_no = ''; filters.user_code = ''; dateRange.value = []; recordPage.page = 1; void loadRecords() }
onMounted(() => { void loadRecords() })
</script>
<style scoped>
.produce-query-page { min-height: calc(100vh - 60px); padding: 28px; color: #17233d; background: #f5f7fb; }
.page-shell { max-width: 1440px; margin: 0 auto; }
.page-hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 32px 36px; color: #fff; background: linear-gradient(135deg, #315efb, #6a67df); border-radius: 22px; box-shadow: 0 14px 32px rgba(49, 94, 251, .16); }
.eyebrow { font-size: 13px; opacity: .85; }
.page-hero h1 { margin: 8px 0 0; font-size: 30px; }
.page-hero p { margin: 8px 0 0; opacity: .86; }
.hero-badge { min-width: 150px; padding: 16px 20px; text-align: right; background: rgba(255, 255, 255, .16); border-radius: 14px; }
.hero-badge strong, .hero-badge span { display: block; }
.hero-badge strong { font-size: 28px; }
.hero-badge span { margin-top: 5px; font-size: 13px; opacity: .82; }
.panel { margin-top: 20px; padding: 26px; background: #fff; border: 1px solid #e7edf5; border-radius: 18px; box-shadow: 0 8px 24px rgba(36, 59, 92, .04); }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.panel-heading h2 { margin: 0; font-size: 18px; }
.panel-heading p { margin: 6px 0 0; color: #8a94a6; font-size: 13px; }
.search-row { display: flex; gap: 12px; }
.search-row .el-input { flex: 1; }
.timeline-alert { margin-top: 16px; }
.timeline-list { margin: 24px 0 0; overflow-x: auto; }
.timeline-table, .record-table { min-width: 1100px; overflow: hidden; border: 1px solid #e7edf5; border-radius: 12px; }
.timeline-table :deep(.el-table__header th), .record-table :deep(.el-table__header th) { height: 48px; color: #5d6b82; font-size: 13px; font-weight: 600; background: #f7f9fc; }
.timeline-table :deep(.el-table__cell), .record-table :deep(.el-table__cell) { padding: 12px 0; color: #34425b; font-size: 13px; }
.timeline-table :deep(.el-table__row:hover > td), .record-table :deep(.el-table__row:hover > td) { background: #f4f7ff !important; }
.timeline-content-cell, .timeline-node-cell { display: flex; align-items: center; min-width: 0; }
.timeline-content-cell { gap: 10px; }
.timeline-main-text, .timeline-node-cell, .timeline-operator-cell { min-width: 0; }
.timeline-content-cell strong, .timeline-node-cell strong { display: block; overflow: hidden; color: #24324b; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.timeline-content-cell small, .timeline-node-cell small, .timeline-operator-cell small { display: block; margin-top: 4px; overflow: hidden; color: #8a94a6; font-size: 12px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.content-mark { width: 8px; height: 8px; flex: none; background: #315efb; border-radius: 50%; box-shadow: 0 0 0 4px #e5ebff; }
.timeline-status { color: #3157c8; font-weight: 500; }
.timeline-detail-cell { display: grid; gap: 5px; min-width: 0; line-height: 1.35; }
.timeline-detail-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.timeline-detail-cell b { display: inline-block; min-width: 64px; margin-right: 6px; color: #8a94a6; font-size: 12px; font-weight: 500; }
.muted-placeholder { color: #b5bdca; }
.filter-form { display: flex; flex-wrap: wrap; margin-bottom: 4px; }
.record-table { margin-top: 14px; }
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; color: #8a94a6; font-size: 13px; }
.pagination-row .el-pagination { margin-left: auto; }
@media (max-width: 640px) { .produce-query-page { padding: 16px; } .page-hero { align-items: flex-start; flex-direction: column; padding: 24px; } .hero-badge { align-self: stretch; text-align: left; } .search-row { flex-direction: column; } .panel { padding: 18px; } .pagination-row { align-items: flex-start; flex-direction: column; } .pagination-row .el-pagination { margin-left: 0; } }
</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div class="produce-work-page repair-page">
<div class="page-shell">
<section class="page-hero repair-hero">
<div><span class="eyebrow">维修工作台</span><h1>维修处理</h1><p>扫描设备,快速完成故障判断、备件登记与维修记录。</p></div>
<el-icon :size="44"><Tools /></el-icon>
</section>
<section class="summary-grid">
<div v-for="item in summaries" :key="item.label" class="summary-card"><strong :class="item.type">{{ item.value }}</strong><span>{{ item.label }}</span><el-button v-if="item.action" link type="primary" @click="goTasks">查看任务</el-button></div>
</section>
<div class="content-grid">
<main class="main-column">
<section class="panel">
<div class="panel-title"><span>1</span><div><h2>扫描或录入设备</h2><p>支持订单号、整机编码或设备条码</p></div><em>必填</em></div>
<div class="inline-form"><el-input v-model="deviceNo" size="large" clearable placeholder="请输入订单号 / 整机编码" @keyup.enter="loadTask" /><el-button type="primary" size="large" :loading="loadingTask" @click="loadTask">查询任务</el-button></div>
<div v-if="task" class="task-found"><el-icon><CircleCheckFilled /></el-icon><div><strong>已识别维修对象</strong><span>{{ task.device_no }}</span></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
</section>
<section class="panel">
<div class="panel-title"><span>2</span><div><h2>异常信息</h2><p>来自异常提报的维修任务</p></div><small>{{ task?.reported_date || '待查询设备' }}</small></div>
<el-empty v-if="!task" description="查询设备后加载异常任务" :image-size="72" />
<div v-else class="info-grid"><div><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div><div><span>异常原因</span><strong>{{ reasonText }}</strong></div><div><span>提报人员</span><strong>{{ task.reporter || task.user_code || '-' }}</strong></div></div>
</section>
<section class="panel">
<div class="panel-title"><span>3</span><div><h2>维修判定</h2><p>选择本次维修的不良类型</p></div><em>必选</em></div>
<div class="result-grid"><button v-for="item in resultOptions" :key="item.value" type="button" :class="['result-card', { active: form.repair_result === item.value }]" @click="selectResult(item.value)"><el-icon :size="26"><component :is="item.icon" /></el-icon><strong>{{ item.label }}</strong><small>{{ item.desc }}</small></button></div>
</section>
<section v-if="form.repair_result === 'assembly_mismatch'" class="panel"><div class="panel-title"><span>4</span><div><h2>搭配错误分类</h2><p>选择与本次异常对应的物料分类</p></div><em>必选</em></div><el-checkbox-group v-model="form.category_ids" class="category-grid"><el-checkbox v-for="item in categories" :key="item.id" :label="String(item.id)">{{ item.category_name }}</el-checkbox></el-checkbox-group><el-empty v-if="!categories.length" description="暂无可用物料分类" :image-size="60" /></section>
<section v-if="form.repair_result === 'operation_defect'" class="panel"><div class="panel-title"><span>4</span><div><h2>作业不良责任</h2><p>选择责任人员,工序步骤随人员记录保存</p></div><em>必选</em></div><el-checkbox-group v-model="form.responsible_ids" class="responsibility-grid"><el-checkbox v-for="item in scanRecords" :key="item.id" :label="String(item.id)"><span>{{ item.operator || item.user_code || '未记录人员' }}</span><small>{{ item.process_name || '-' }} · {{ item.step_name || '-' }}</small></el-checkbox></el-checkbox-group><el-empty v-if="!scanRecords.length" description="当前订单暂无可定责的历史扫码记录" :image-size="60" /></section>
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '4' : '5' }}</span><div><h2>维修记录</h2><p>登记更换备件和处理说明,便于后续追溯</p></div></div><div class="part-toolbar"><strong>更换备件</strong><div><el-button size="small" @click="openPartDialog">输入商品编码</el-button><el-button size="small" type="primary" @click="openPartDialog">添加备件</el-button></div></div><el-empty v-if="!form.parts.length" description="暂未添加备件" :image-size="60" /><div v-for="(part, index) in form.parts" :key="part.id" class="part-row"><div class="part-index">{{ index + 1 }}</div><div class="part-info"><strong>{{ part.product_name || part.product_code }}</strong><span>{{ part.product_code }}{{ part.category_name ? ` · ${part.category_name}` : '' }}</span><el-radio-group v-model="part.source" size="small"><el-radio-button label="new">新料</el-radio-button><el-radio-button label="after_sales">售后</el-radio-button><el-radio-button label="refurbished">返修</el-radio-button></el-radio-group><el-select v-model="part.fault_description_ids" multiple collapse-tags filterable clearable placeholder="选择故障点" class="fault-select"><el-option v-for="fault in faultsForPart(part)" :key="fault.id" :label="fault.description" :value="String(fault.id)" /></el-select></div><el-input-number v-model="part.quantity" :min="1" size="small" /><el-button link type="danger" @click="removePart(index)">删除</el-button></div><el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="记录维修过程、处理建议或补充说明" /></section>
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '5' : '6' }}</span><div><h2>维修凭证</h2><p>可上传维修完成后的现场照片</p></div><em class="optional">选填</em></div><el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed"><span class="upload-plus">+</span></el-upload></section>
<div class="form-actions"><el-button size="large" @click="resetForm">重置</el-button><el-button type="primary" size="large" :loading="submitting" @click="completeRepair">完成维修</el-button></div>
</main>
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>姓名</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div></section><section class="panel tip-panel"><h2>处理提示</h2><p>物料不良请登记替换备件;作业不良需选择责任人员;搭配错误需选择对应物料分类。</p><el-button link type="primary" @click="goTasks">查看待维修订单</el-button></section></aside>
</div>
</div>
<el-dialog v-model="partDialogVisible" title="添加维修备件" width="460px"><el-input v-model="partCode" autofocus placeholder="请输入商品编码" @keyup.enter="addPart" /><template #footer><el-button @click="partDialogVisible = false">取消</el-button><el-button type="primary" :loading="loadingPart" @click="addPart">确定</el-button></template></el-dialog>
</div>
</template>
<script setup lang="ts">
import { CircleCheckFilled, Grid, EditPen, Tools, Box } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadUserFile } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { navigateTo, useRoute } from 'nuxt/app'
import { useMemberStore } from '~/stores/member'
import { getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, lookupMaterial, submitRepairRecord, uploadProduceImage, type ProduceRepairTask } from '~/apps/produce/api/exception'
interface Part { id: string; product_code: string; product_name?: string; category_id?: string | number; category_name?: string; source: string; quantity: number; fault_description_ids: string[] }
interface ScanRecord { id: string | number; process_name?: string; step_name?: string; operator?: string; user_code?: string }
interface FaultDescription { id: string | number; category_id: string | number; description: string }
const memberStore = useMemberStore()
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
const summaries = ref([{ label: '待维修', value: 0, type: 'warning', action: true }, { label: '处理中', value: 0, type: 'primary' }, { label: '今日完成', value: 0, type: 'success' }])
const resultOptions = [{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: Box }, { label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: EditPen }, { label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: Grid }]
const deviceNo = ref('')
const task = ref<ProduceRepairTask | null>(null)
const categories = ref<Array<{ id: string | number; category_name: string }>>([])
const scanRecords = ref<ScanRecord[]>([])
const faults = ref<FaultDescription[]>([])
const imageFiles = ref<UploadUserFile[]>([])
const form = ref({ repair_result: 'material_defect', category_ids: [] as string[], responsible_ids: [] as string[], parts: [] as Part[], remark: '' })
const loadingTask = ref(false)
const loadingPart = ref(false)
const submitting = ref(false)
const partDialogVisible = ref(false)
const partCode = ref('')
const reasonText = computed(() => [task.value?.reason_name, task.value?.issue_name].filter(Boolean).join(' · ') || '-')
const loadStatistics = async () => {
const data = await getRepairStatistics({ user_code: currentUser.value.code }).catch(() => null)
if (!data) return
summaries.value = [
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: true },
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' },
]
}
const loadTask = async () => { const value = deviceNo.value.trim(); if (!value || loadingTask.value) return ElMessage.warning('请输入设备编码或订单号'); loadingTask.value = true; try { task.value = await getRepairTask(value); form.value.responsible_ids = []; scanRecords.value = []; if (task.value?.device_no) { const result = await getScanRecords({ order_no: task.value.device_no, limit: 50 }); scanRecords.value = Array.isArray(result?.items) ? result.items : []; } } finally { loadingTask.value = false } }
const selectResult = (value: string) => { form.value.repair_result = value; if (value !== 'assembly_mismatch') form.value.category_ids = []; if (value !== 'operation_defect') form.value.responsible_ids = [] }
const openPartDialog = () => { partCode.value = ''; partDialogVisible.value = true }
const addPart = async () => { const code = partCode.value.trim(); if (!code) return ElMessage.warning('请输入商品编码'); if (form.value.parts.some((item: Part) => item.product_code === code)) return ElMessage.warning('该物料已添加'); loadingPart.value = true; try { const material = await lookupMaterial(code); const part: Part = { id: `${Date.now()}-${code}`, product_code: material.product_code || code, product_name: material.product_name, category_id: material.category_id, category_name: material.category_name, source: 'new', quantity: 1, fault_description_ids: [] }; form.value.parts.push(part); await loadFaults(); partDialogVisible.value = false } finally { loadingPart.value = false } }
const loadFaults = async () => { const ids = [...new Set(form.value.parts.map((item: Part) => String(item.category_id || '')).filter(Boolean))]; faults.value = ids.length ? await getFaultDescriptionOptions(ids) : [] }
const faultsForPart = (part: Part) => faults.value.filter((item: FaultDescription) => String(item.category_id) === String(part.category_id))
const removePart = (index: number) => { form.value.parts.splice(index, 1); void loadFaults() }
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
const resetForm = () => { deviceNo.value = ''; task.value = null; scanRecords.value = []; form.value = { repair_result: 'material_defect', category_ids: [], responsible_ids: [], parts: [], remark: '' }; imageFiles.value = [] }
const completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!currentUser.value.code) return ElMessage.warning('请先登录'); if (form.value.repair_result === 'assembly_mismatch' && !form.value.category_ids.length) return ElMessage.warning('请选择搭配错误分类'); if (form.value.repair_result === 'operation_defect' && !form.value.responsible_ids.length) return ElMessage.warning('请选择责任人员'); submitting.value = true; try { const images: string[] = []; for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'repair'); if (url) images.push(url) } const response = await submitRepairRecord({ exception_id: task.value.id, user_code: currentUser.value.code, repair_result: form.value.repair_result, images, category_ids: form.value.category_ids, parts: form.value.parts.map((part: Part) => ({ product_code: part.product_code, source: part.source, quantity: part.quantity, fault_description_ids: part.fault_description_ids })), operation_fault_steps: form.value.responsible_ids, responsible_operators: form.value.responsible_ids, remark: form.value.remark.trim() }); if (response?.code !== 0) { ElMessage.error(response?.msg || '维修提交失败'); return } ElMessage.success('维修记录已完成'); resetForm(); void loadStatistics() } finally { submitting.value = false } }
const goTasks = () => navigateTo('/produce/repair/tasks')
onMounted(() => { void loadStatistics(); void getMaterialCategories().then((data: Array<{ id: string | number; category_name: string }>) => { categories.value = Array.isArray(data) ? data : [] }); const query = useRoute().query.device_no; if (query) { deviceNo.value = String(query); void loadTask() } })
</script>
<style scoped>
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;padding:32px 36px;border-radius:22px;color:#fff;box-shadow:0 14px 32px rgba(37,99,235,.12)}.repair-hero{background:linear-gradient(135deg,#2f6cf6,#35bfa3)}.eyebrow{font-size:13px;opacity:.85}h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.summary-card,.panel{background:#fff;border:1px solid #e7edf5;border-radius:18px;box-shadow:0 8px 24px rgba(36,59,92,.04)}.summary-card{display:flex;align-items:center;gap:12px;padding:18px 22px}.summary-card strong{font-size:28px}.summary-card span{color:#8a94a6;font-size:13px}.summary-card .el-button{margin-left:auto}.warning{color:#e59a2f}.primary{color:#2563eb}.success{color:#26b36c}.content-grid{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:20px;align-items:start}.main-column,.side-column{display:flex;flex-direction:column;gap:20px}.panel{padding:26px}.panel-title{display:flex;align-items:flex-start;gap:14px}.panel-title>span{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#eaf2ff;color:#2563eb;font-weight:700;flex:none}.panel-title h2{margin:0;font-size:17px}.panel-title p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.panel-title em{margin-left:auto;padding:5px 10px;border-radius:999px;color:#2563eb;background:#edf4ff;font-size:12px;font-style:normal}.panel-title em.optional{color:#8793a6;background:#f3f5f8}.panel-title small{margin-left:auto;color:#9aa4b4}.inline-form{display:flex;gap:12px;margin-top:20px}.inline-form .el-input{flex:1}.task-found{display:flex;align-items:center;gap:12px;margin-top:16px;padding:14px;border-radius:12px;background:#f0fbf6;color:#26b36c}.task-found>div{display:flex;flex-direction:column;gap:4px;flex:1}.task-found span{color:#2b3853;font-weight:600}.task-found .el-tag{margin-left:auto}.info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:22px}.info-grid div{padding:14px;border-radius:12px;background:#f8fafc}.info-grid span,.info-grid strong{display:block}.info-grid span{color:#9aa4b4;font-size:12px}.info-grid strong{margin-top:8px;font-size:14px;word-break:break-all}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:20px}.result-card{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:18px;border:1px solid #e6edf5;border-radius:14px;background:#f8fafc;color:#56647a;text-align:left;cursor:pointer}.result-card.active{border-color:#409eff;background:#edf4ff;color:#2563eb}.result-card small{color:#9aa4b4}.category-grid,.responsibility-grid{display:flex;flex-wrap:wrap;gap:14px;margin-top:22px}.responsibility-grid .el-checkbox{height:auto;margin:0;padding:12px 16px;border:1px solid #e6edf5;border-radius:12px;background:#f8fafc}.responsibility-grid .el-checkbox.is-checked{border-color:#409eff;background:#edf4ff}.responsibility-grid small{display:block;color:#9aa4b4}.part-toolbar{display:flex;justify-content:space-between;align-items:center;margin:22px 0 12px}.part-row{display:flex;align-items:flex-start;gap:12px;padding:16px 0;border-bottom:1px solid #eef2f7}.part-index{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#edf4ff;color:#2563eb}.part-info{display:flex;flex-direction:column;gap:8px;flex:1}.part-info>span{color:#8a94a6;font-size:12px}.fault-select{width:100%}.remark-input{margin-top:18px}.form-actions{display:flex;justify-content:flex-end;gap:12px}.operator-panel h2,.tip-panel h2{margin:0 0 18px}.operator-panel div{display:flex;justify-content:space-between;padding:13px 0;border-bottom:1px solid #eef2f7;font-size:13px}.operator-panel div:last-child{border:0}.operator-panel span{color:#9aa4b4}.tip-panel p{color:#718096;font-size:13px;line-height:1.8}.upload-plus{font-size:30px;color:#409eff}:deep(.el-upload--picture-card),:deep(.el-upload-list__item){width:92px;height:92px}@media(max-width:900px){.produce-work-page{padding:16px}.content-grid{grid-template-columns:1fr}.side-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.page-hero{padding:24px;align-items:flex-start;flex-direction:column;gap:18px}.summary-grid,.side-column,.info-grid,.result-grid{grid-template-columns:1fr}.inline-form,.form-actions{flex-direction:column}.form-actions .el-button{width:100%}.part-row{flex-wrap:wrap}}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="produce-work-page tasks-page">
<div class="page-shell">
<section class="page-hero">
<div><span class="eyebrow">维修工作台</span><h1>待维修订单</h1><p>选择订单查看异常详情,并进入维修处理。</p></div>
<div class="task-count">{{ tasks.length }} 条</div>
</section>
<section class="panel">
<div class="panel-heading"><div><h2>待维修任务</h2><p>按异常提报时间展示尚未处理的维修任务</p></div><el-button :loading="loading" @click="loadTasks">刷新</el-button></div>
<el-skeleton v-if="loading" :rows="5" animated />
<el-empty v-else-if="!tasks.length" description="暂无待维修订单" :image-size="90" />
<div v-else class="task-grid">
<article v-for="task in tasks" :key="task.id" class="task-card">
<div class="task-card-header"><div><span class="task-label">设备 / 订单号</span><strong>{{ task.device_no }}</strong></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
<div class="task-detail"><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div>
<div class="task-detail"><span>异常原因</span><strong>{{ reasonText(task) }}</strong></div>
<div class="task-footer"><span>{{ task.reported_date || '-' }}</span><span>{{ task.reporter || task.user_code || '-' }}</span></div>
<el-button type="primary" class="task-action" @click="startRepair(task.device_no)">开始维修</el-button>
</article>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getRepairTasks, type ProduceRepairTask } from '~/apps/produce/api/exception'
const loading = ref(false)
const tasks = ref<ProduceRepairTask[]>([])
const reasonText = (task: ProduceRepairTask) => [task.reason_name, task.issue_name].filter(Boolean).join(' · ') || '-'
const loadTasks = async () => {
loading.value = true
try {
const result = await getRepairTasks({ limit: 100 })
tasks.value = Array.isArray(result) ? result : []
} catch {
tasks.value = []
} finally {
loading.value = false
}
}
const startRepair = (deviceNo: string) => navigateTo({ path: '/produce/repair', query: { device_no: deviceNo } })
onMounted(() => { void loadTasks() })
</script>
<style scoped>
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:32px 36px;border-radius:22px;color:#fff;background:linear-gradient(135deg,#2f6cf6,#35bfa3);box-shadow:0 14px 32px rgba(37,99,235,.12)}.eyebrow{font-size:13px;opacity:.85}.page-hero h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.task-count{padding:10px 16px;border-radius:999px;background:rgba(255,255,255,.16);font-size:14px}.panel{margin-top:20px;padding:26px;border:1px solid #e7edf5;border-radius:18px;background:#fff;box-shadow:0 8px 24px rgba(36,59,92,.04)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}.panel-heading h2{margin:0;font-size:18px}.panel-heading p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.task-card{padding:20px;border:1px solid #e7edf5;border-radius:16px;background:#fbfcfe}.task-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.task-label,.task-detail span{display:block;color:#9aa4b4;font-size:12px}.task-card-header strong{display:block;margin-top:7px;color:#2563eb;font-size:16px;word-break:break-all}.task-detail{display:flex;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid #eef2f7}.task-detail strong{max-width:68%;text-align:right;font-size:13px;word-break:break-all}.task-footer{display:flex;justify-content:space-between;gap:12px;padding:14px 0;color:#8a94a6;font-size:12px}.task-action{width:100%;margin-top:4px}@media(max-width:1000px){.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.produce-work-page{padding:16px}.page-hero{padding:24px;align-items:flex-start;flex-direction:column}.task-grid{grid-template-columns:1fr}.panel{padding:18px}}
</style>
+42
View File
@@ -0,0 +1,42 @@
export default [
{
path: '/produce/data-entry',
component: () => import('~/apps/produce/pages/data-entry/index.vue'),
meta: {
title: '数据录入',
layout: 'default',
},
},
{
path: '/produce/exception',
component: () => import('~/apps/produce/pages/exception/index.vue'),
meta: {
title: '异常处理',
layout: 'default',
},
},
{
path: '/produce/repair',
component: () => import('~/apps/produce/pages/repair/index.vue'),
meta: {
title: '维修处理',
layout: 'default',
},
},
{
path: '/produce/repair/tasks',
component: () => import('~/apps/produce/pages/repair/tasks.vue'),
meta: {
title: '待维修订单',
layout: 'default',
},
},
{
path: '/produce/query',
component: () => import('~/apps/produce/pages/query/index.vue'),
meta: {
title: '综合查询',
layout: 'default',
},
},
]
+24 -12
View File
@@ -3,6 +3,7 @@ import qs from 'qs'
import { getToken, getRefreshToken } from './common'
import { useRuntimeConfig, navigateTo, useCookie } from 'nuxt/app'
import { useMemberStore } from '~/stores/member'
import { decryptEncryptedResponse, prepareEncryptedRequest } from '~/utils/security/api-encrypt'
// 响应数据类型定义
interface ResponseData<T = any> {
@@ -15,7 +16,7 @@ interface ResponseData<T = any> {
interface ConfigOption {
showErrorMessage?: boolean
showSuccessMessage?: boolean,
headers?: Headers
headers?: HeadersInit
// 控制是否返回完整响应对象(默认 false 返回 data)
returnFullResponse?: boolean
// 响应类型
@@ -215,19 +216,28 @@ class Http {
}
// 构建请求选项
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(this.options.headers as Record<string, string>),
...((config.headers || {}) as Record<string, string>),
...((param.headers || {}) as Record<string, string>)
}
const requestOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...this.options.headers,
...(config.headers || {}),
...(param.headers || {})
}
headers: requestHeaders
}
let encryptSession: any = null
// 添加请求体
if (param.body) {
requestOptions.body = JSON.stringify(param.body)
const encryptedRequest = await prepareEncryptedRequest(baseURL, `/${normalizedUrl}`, method, param.body)
encryptSession = encryptedRequest.session
Object.assign(requestHeaders, encryptedRequest.headers)
if (encryptedRequest.body) {
requestOptions.body = encryptedRequest.body
}
// 发送请求
@@ -248,11 +258,12 @@ class Http {
if (responseType === 'json') {
data = await response.json()
data = await decryptEncryptedResponse(data, encryptSession)
} else if (responseType === 'blob') {
// 对于blob类型,直接读取blob并返回
try {
const blob = await response.blob()
resolve(blob)
resolve(blob as T)
return
} catch (error) {
console.error('[request] Error reading blob:', error)
@@ -304,10 +315,11 @@ class Http {
console.log('[request] New token:', newToken?.substring(0, 20) + '...')
// 重新发送请求
this.options.headers['Authorization'] = `Bearer ${newToken}`
requestOptions.headers['Authorization'] = `Bearer ${newToken}`
requestHeaders['Authorization'] = `Bearer ${newToken}`
return fetch(fullURL, requestOptions)
}).then(async (newResponse) => {
const newData = await newResponse.json()
let newData = await newResponse.json()
newData = await decryptEncryptedResponse(newData, encryptSession)
if (newData.code == 0) {
if (config.returnFullResponse) {
resolve(newData as T)
+276
View File
@@ -0,0 +1,276 @@
interface PublicKeyResponse {
enabled: boolean
scope: string
algorithm: string
key_version: string
public_key: string
encrypted_paths?: string[]
}
interface EncryptSession {
aesKey: Uint8Array
iv: Uint8Array
keyVersion: string
encryptedKey: string
encryptedIv: string
}
interface EncryptRequestResult {
body: string
headers: Record<string, string>
session: EncryptSession | null
}
const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key'
let publicKeyCache: PublicKeyResponse | null = null
let publicKeyFailureAt = 0
const PUBLIC_KEY_RETRY_INTERVAL = 30000
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
function normalizeBaseUrl(baseURL: string): string {
return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
}
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
function resolveRequestPath(baseURL: string, path: string): string {
const normalizedBaseUrl = normalizeBaseUrl(baseURL)
const normalizedPath = normalizePath(path)
if (/^https?:\/\//i.test(normalizedPath)) {
const url = new URL(normalizedPath)
return url.pathname
}
if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) {
return normalizedPath
}
try {
const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin)
return url.pathname
} catch {
return normalizedPath
}
}
function shouldSkipEncrypt(path: string): boolean {
return path === '/api/auth/refresh'
|| path === '/adminapi/auth/refresh'
|| path === '/api/system/security/api-encrypt/public-key'
|| path === '/adminapi/system/security/api-encrypt/public-key'
}
function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean {
const normalizedPath = normalizePath(path)
return encryptedPaths.some((encryptedPath) => {
const normalizedEncryptedPath = normalizePath(encryptedPath)
return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`)
})
}
function toBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
let binary = ''
bytes.forEach((byte) => {
binary += String.fromCharCode(byte)
})
return btoa(binary)
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
function fromPem(pem: string): ArrayBuffer {
const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/\s+/g, '')
const binary = atob(clean)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
async function importPublicKey(publicKeyPem: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'spki',
fromPem(publicKeyPem),
{
name: 'RSA-OAEP',
hash: 'SHA-256'
},
true,
['encrypt']
)
}
async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise<string> {
const publicKey = await importPublicKey(publicKeyPem)
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data)
return toBase64(encrypted)
}
async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(aesKey),
{ name: 'AES-CBC' },
false,
['encrypt']
)
const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText))
return toBase64(encrypted)
}
async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(aesKey),
{ name: 'AES-CBC' },
false,
['decrypt']
)
const binary = atob(cipherText)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes))
return textDecoder.decode(decrypted)
}
async function fetchPublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}`
const response = await fetch(requestUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const result = await response.json()
if (!result || result.code !== 0 || !result.data) {
return null
}
return result.data as PublicKeyResponse
}
async function ensurePublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
if (publicKeyCache) {
return publicKeyCache
}
if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) {
return null
}
let data: PublicKeyResponse | null = null
try {
data = await fetchPublicKey(baseURL)
} catch {
publicKeyFailureAt = Date.now()
return null
}
if (!data) {
publicKeyFailureAt = Date.now()
return null
}
publicKeyFailureAt = 0
publicKeyCache = data
return publicKeyCache
}
export function clearApiEncryptPublicKeyCache() {
publicKeyCache = null
publicKeyFailureAt = 0
}
export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise<EncryptRequestResult> {
const resolvedPath = resolveRequestPath(baseURL, path)
if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
if (shouldSkipEncrypt(resolvedPath)) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
const publicKeyData = await ensurePublicKey(baseURL)
if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
const aesKey = crypto.getRandomValues(new Uint8Array(32))
const iv = crypto.getRandomValues(new Uint8Array(16))
const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey)
const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv)
let encryptedBody = ''
if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) {
const payload = JSON.stringify(body)
encryptedBody = await aesEncrypt(payload, aesKey, iv)
}
return {
body: encryptedBody === '' ? '' : JSON.stringify({
encrypted: true,
algorithm: publicKeyData.algorithm,
key_version: publicKeyData.key_version,
data: encryptedBody,
method
}),
headers: {
'X-Api-Encrypt': '1',
'X-Api-Encrypt-Version': publicKeyData.key_version,
'X-Api-Encrypt-Key': encryptedKey,
'X-Api-Encrypt-Iv': encryptedIv
},
session: {
aesKey,
iv,
keyVersion: publicKeyData.key_version,
encryptedKey,
encryptedIv
}
}
}
export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise<any> {
if (!data || data.encrypted !== true || !session) {
return data
}
const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv)
return JSON.parse(plainText)
}
+1 -1
View File
@@ -3613,7 +3613,7 @@
"simple-icons:stackbit",
"simple-icons:stackblitz",
"simple-icons:stackedit",
"simple-icons:stackexchange",
"simple-icons:stacmaschange",
"simple-icons:stackhawk",
"simple-icons:stackoverflow",
"simple-icons:stackshare",
+1 -1
View File
@@ -2780,7 +2780,7 @@
"simple-icons:stackbit",
"simple-icons:stackblitz",
"simple-icons:stackedit",
"simple-icons:stackexchange",
"simple-icons:stacmaschange",
"simple-icons:stackhawk",
"simple-icons:stackoverflow",
"simple-icons:stackshare",