完善生产异常维修与备件管理功能

This commit is contained in:
2026-09-01 19:17:09 +08:00
parent 02a429af66
commit 32f1486f5e
49 changed files with 1765 additions and 121 deletions
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return new class {
public function up(Builder $schema): void
{
if (!$schema->hasTable('produce_exception_record')) {
return;
}
$schema->table('produce_exception_record', function (Blueprint $table) use ($schema): void {
if (!$schema->hasColumn('produce_exception_record', 'process_id')) {
$table->bigInteger('process_id')->nullable()->comment('异常提报工序ID');
$table->index('process_id', 'idx_exception_record_process_id');
}
if (!$schema->hasColumn('produce_exception_record', 'step_id')) {
$table->bigInteger('step_id')->nullable()->comment('异常提报步骤ID');
$table->index('step_id', 'idx_exception_record_step_id');
}
});
}
public function down(Builder $schema): void
{
if (!$schema->hasTable('produce_exception_record')) {
return;
}
$schema->table('produce_exception_record', function (Blueprint $table) use ($schema): void {
if ($schema->hasColumn('produce_exception_record', 'process_id')) {
$table->dropIndex('idx_exception_record_process_id');
$table->dropColumn('process_id');
}
if ($schema->hasColumn('produce_exception_record', 'step_id')) {
$table->dropIndex('idx_exception_record_step_id');
$table->dropColumn('step_id');
}
});
}
};
@@ -394,16 +394,21 @@ export interface ProduceRepairRecordRow {
product_name?: string | null;
category_id?: string | number | null;
category_name?: string | null;
category_ids?: Array<string | number> | string | null;
category_names?: string[] | string | null;
fault_description_id?: string | number | null;
fault_description_ids?: Array<string | number>;
fault_description?: string | null;
fault_descriptions?: string[];
repair_result?: string | null;
parts?: Array<Record<string, any>> | string | null;
images?: string[] | string | null;
operation_fault_steps?: Array<Record<string, any>> | string | null;
responsible_operators?: Array<Record<string, any>> | string | null;
operator?: string | null;
repaired_at?: number | null;
repaired_date?: string | null;
repair_result_name?: string;
remark?: string | null;
created_date?: string;
}
@@ -30,6 +30,12 @@ const settingList = ref([
name: '基础设置',
loadKey: 0
},
{
component: markRaw(defineAsyncComponent(() => import('./modules/repair-flow.vue'))),
key: 'repair-flow',
name: '维修流程',
loadKey: 0
},
{
component: markRaw(defineAsyncComponent(() => import('./modules/step-rule.vue'))),
key: 'step-rule',
@@ -122,7 +122,7 @@ const [BasicForm, formApi] = useForm({
class: 'w-full'
}
},
{
{
prop: 'recent_records_today_only',
label: '最近记录只显示当日',
component: 'RadioGroup',
@@ -133,6 +133,54 @@ const [BasicForm, formApi] = useForm({
{ label: '否', value: 0 }
]
}
},
{
prop: 'exception_process_id',
label: '异常提报工序',
component: 'ApiSelect',
componentProps: {
api: '/produce/process',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择异常提报对应工序',
},
},
{
prop: 'exception_step_id',
label: '异常提报步骤',
component: 'ApiSelect',
componentProps: {
api: '/produce/process-step',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择异常提报对应步骤',
},
},
{
prop: 'repair_process_id',
label: '维修记录工序',
component: 'ApiSelect',
componentProps: {
api: '/produce/process',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择维修记录对应工序',
},
},
{
prop: 'repair_step_id',
label: '维修记录步骤',
component: 'ApiSelect',
componentProps: {
api: '/produce/process-step',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择维修记录对应步骤',
},
}
]
})
@@ -156,7 +204,11 @@ async function onSubmit() {
{ code: 'product_code_max_length', name: '商品编码最大长度', content: values.product_code_max_length ?? 20 },
{ code: 'global_allow_repeat', name: '全局允许重复', content: Number(values.global_allow_repeat ?? 0) },
{ code: 'repeat_scan_interval', name: '重复扫码最小间隔', content: values.repeat_scan_interval ?? 0 },
{ code: 'recent_records_today_only', name: '最近记录只显示当日', content: Number(values.recent_records_today_only ?? 1) }
{ code: 'recent_records_today_only', name: '最近记录只显示当日', content: Number(values.recent_records_today_only ?? 1) },
{ code: 'exception_process_id', name: '异常提报工序', content: values.exception_process_id ?? '' },
{ code: 'exception_step_id', name: '异常提报步骤', content: values.exception_step_id ?? '' },
{ code: 'repair_process_id', name: '维修记录工序', content: values.repair_process_id ?? '' },
{ code: 'repair_step_id', name: '维修记录步骤', content: values.repair_step_id ?? '' }
]
})
ElMessage.success('保存成功')
@@ -0,0 +1,87 @@
<template>
<div class="repair-flow-page">
<BasicForm />
<el-button class="site-submit" type="primary" :loading="saving" @click="onSubmit">保存配置</el-button>
</div>
</template>
<script setup lang="tsx">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useForm } from '@/components/form'
import { ProduceConfigApi } from '@/apps/produce/api'
const saving = ref(false)
const [BasicForm, formApi] = useForm({
labelPosition: 'right',
labelWidth: '160px',
inline: false,
schema: [
{
prop: 'repair_process_id',
label: '维修流程工序',
component: 'ApiSelect',
componentProps: {
api: '/produce/process',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择维修流程对应工序',
},
},
{
prop: 'repair_step_id',
label: '维修流程步骤',
component: 'ApiSelect',
componentProps: {
api: '/produce/process-step',
requestMethod: 'get',
params: { format: 'select' },
clearable: true,
placeholder: '请选择维修流程对应步骤',
},
},
],
})
const loadConfig = async () => {
const current = await ProduceConfigApi.info({ group_code: 'repair_flow' }) as Record<string, any>
if (current?.repair_process_id || current?.repair_step_id) {
await formApi.setValues(current)
return
}
const legacy = await ProduceConfigApi.info({ group_code: 'basic_setting' }) as Record<string, any>
await formApi.setValues({
repair_process_id: legacy?.repair_process_id || '',
repair_step_id: legacy?.repair_step_id || '',
})
}
const onSubmit = async () => {
if (saving.value) return
saving.value = true
try {
const values = await formApi.getValues()
await ProduceConfigApi.save({
group_code: 'repair_flow',
configs: [
{ code: 'repair_process_id', name: '维修流程工序', content: values.repair_process_id ?? '' },
{ code: 'repair_step_id', name: '维修流程步骤', content: values.repair_step_id ?? '' },
],
})
ElMessage.success('保存成功')
} finally {
saving.value = false
}
}
onMounted(() => {
void loadConfig()
})
</script>
<style scoped>
.site-submit {
margin-left: 160px;
}
</style>
@@ -0,0 +1,228 @@
<template>
<div class="exception-analysis-page">
<div class="analysis-toolbar">
<el-date-picker
v-model="filters.reported_at_range"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-input v-model="filters.type_code" clearable placeholder="处理类型编码" @keyup.enter="loadData" />
<el-select v-model="filters.status" clearable placeholder="处理状态">
<el-option label="待处理" :value="0" />
<el-option label="处理中" :value="1" />
<el-option label="已处理" :value="2" />
</el-select>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<div class="metric-grid">
<div class="metric-item"><span>异常总数</span><strong>{{ overview.total }}</strong></div>
<div class="metric-item pending"><span>待处理</span><strong>{{ overview.pending }}</strong></div>
<div class="metric-item processing"><span>处理中</span><strong>{{ overview.processing }}</strong></div>
<div class="metric-item completed"><span>已处理</span><strong>{{ overview.completed }}</strong></div>
</div>
<div class="analysis-grid" v-loading="loading">
<section class="analysis-section">
<h3>处理类型分布</h3>
<el-table :data="overview.by_type" size="small" height="260" empty-text="暂无数据">
<el-table-column prop="name" label="处理类型" min-width="150" />
<el-table-column prop="total" label="数量" width="100" align="right" />
</el-table>
</section>
<section class="analysis-section">
<h3>异常原因排行</h3>
<el-table :data="overview.by_reason" size="small" height="260" empty-text="暂无数据">
<el-table-column type="index" label="排名" width="70" />
<el-table-column prop="name" label="异常原因" min-width="150" />
<el-table-column prop="total" label="数量" width="100" align="right" />
</el-table>
</section>
<section class="analysis-section">
<h3>具体问题排行</h3>
<el-table :data="overview.by_issue" size="small" height="260" empty-text="暂无数据">
<el-table-column type="index" label="排名" width="70" />
<el-table-column prop="name" label="具体问题" min-width="150" />
<el-table-column prop="total" label="数量" width="100" align="right" />
</el-table>
</section>
<section class="analysis-section full-width">
<h3>每日提报趋势</h3>
<el-table :data="overview.by_date" size="small" height="260" empty-text="暂无数据">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="date" label="日期" min-width="180" />
<el-table-column prop="total" label="异常数量" width="130" align="right" />
</el-table>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import request from '@/utils/http'
interface AnalysisItem {
code: string
name: string
total: number
}
interface DateItem {
date: string
total: number
}
interface AnalysisOverview {
total: number
pending: number
processing: number
completed: number
by_type: AnalysisItem[]
by_reason: AnalysisItem[]
by_issue: AnalysisItem[]
by_date: DateItem[]
}
defineOptions({ name: 'ProduceExceptionAnalysis' })
const defaultRange = (): [string, string] => {
const today = new Date()
const start = new Date(today)
start.setDate(today.getDate() - 29)
const format = (date: Date) => date.toISOString().slice(0, 10)
return [format(start), format(today)]
}
const filters = reactive({
reported_at_range: defaultRange() as string[] | undefined,
type_code: '',
status: undefined as number | undefined,
})
const loading = ref(false)
const overview = ref<AnalysisOverview>({
total: 0,
pending: 0,
processing: 0,
completed: 0,
by_type: [],
by_reason: [],
by_issue: [],
by_date: [],
})
async function loadData() {
loading.value = true
try {
overview.value = await request.get<AnalysisOverview>({
url: '/produce/exception-analysis/overview',
params: filters,
})
} finally {
loading.value = false
}
}
function resetFilters() {
filters.reported_at_range = defaultRange()
filters.type_code = ''
filters.status = undefined
loadData()
}
onMounted(loadData)
</script>
<style scoped>
.exception-analysis-page {
min-height: 100%;
padding: 16px;
background: var(--el-bg-color-page);
}
.analysis-toolbar,
.metric-grid,
.analysis-grid {
display: grid;
gap: 14px;
}
.analysis-toolbar {
grid-template-columns: minmax(260px, 1fr) 180px 150px auto auto;
align-items: center;
padding: 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
}
.metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 16px 0;
}
.metric-item,
.analysis-section {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
}
.metric-item {
padding: 18px;
}
.metric-item span,
.metric-item strong {
display: block;
}
.metric-item span {
color: var(--el-text-color-secondary);
font-size: 14px;
}
.metric-item strong {
margin-top: 8px;
color: var(--el-text-color-primary);
font-size: 28px;
line-height: 1;
}
.metric-item.pending strong { color: var(--el-color-danger); }
.metric-item.processing strong { color: var(--el-color-warning); }
.metric-item.completed strong { color: var(--el-color-success); }
.analysis-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.analysis-section {
min-width: 0;
padding: 14px;
}
.analysis-section h3 {
margin: 0 0 12px;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.full-width {
grid-column: 1 / -1;
}
@media (max-width: 1100px) {
.analysis-toolbar,
.analysis-grid { grid-template-columns: 1fr 1fr; }
.metric-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 700px) {
.analysis-toolbar,
.analysis-grid,
.metric-grid { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,22 @@
<template>
<div class="produce-exception-data-page art-full-height">
<BasicCrud />
</div>
</template>
<script setup lang="tsx">
import { useCrud } from '@/components/crud'
import { crudSchema } from './schemas'
defineOptions({ name: 'ProduceExceptionDataList' })
const [BasicCrud] = useCrud({
...crudSchema(),
})
</script>
<style scoped>
.produce-exception-data-page {
height: 100%;
}
</style>
@@ -0,0 +1,187 @@
import { CrudSchema } from '@/components/crud'
import { ProduceExceptionRecordService } from '@/apps/produce/api'
const statusOptions = [
{ label: '待处理', value: 0 },
{ label: '处理中', value: 1 },
{ label: '已处理', value: 2 }
]
export const crudSchema = (): CrudSchema => ({
api: ProduceExceptionRecordService.list,
dialogTitle: '异常数据',
crudApi: {
add: ProduceExceptionRecordService.create,
edit: ProduceExceptionRecordService.update,
remove: ProduceExceptionRecordService.remove,
view: ProduceExceptionRecordService.get
},
useCrud: true,
hasAdd: true,
hasEdit: true,
hasRemove: true,
hasView: true,
addAuth: 'produce:exception-data:create',
editAuth: 'produce:exception-data:update',
removeAuth: 'produce:exception-data:delete',
viewAuth: 'produce:exception-data:read',
rowKey: 'id',
searchFormSchema: {
labelWidth: '90px',
schema: [
{
prop: 'keyword',
label: '关键字',
component: 'Input',
componentProps: { clearable: true, placeholder: '单号、提报人、处理类型、原因或具体问题' },
colSpan: 8
},
{
prop: 'type_code',
label: '处理类型',
component: 'Input',
componentProps: { clearable: true, placeholder: '请输入处理类型编码' },
colSpan: 8
},
{
prop: 'status',
label: '处理状态',
component: 'Select',
componentProps: { clearable: true, placeholder: '请选择处理状态', options: statusOptions },
colSpan: 8
},
{
prop: 'reported_at_range',
label: '提报时间',
component: 'DatePicker',
componentProps: {
type: 'daterange',
clearable: true,
valueFormat: 'YYYY-MM-DD',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期'
},
colSpan: 12
}
]
},
columns: [
{ type: 'selection' },
{ type: 'index', label: '序号', width: 70 },
{ prop: 'order_no', label: '单号/条码', minWidth: 160 },
{ prop: 'type_name', label: '处理类型', minWidth: 120 },
{ prop: 'reason_name', label: '异常原因', minWidth: 120 },
{ prop: 'issue_name', label: '具体问题', minWidth: 140 },
{ prop: 'reporter', label: '提报人', minWidth: 110 },
{ prop: 'status_name', label: '处理状态', minWidth: 100 },
{ prop: 'handler', label: '处理人', minWidth: 110 },
{ prop: 'reported_date', label: '提报时间', minWidth: 170 },
{ prop: 'handled_date', label: '处理时间', minWidth: 170 }
],
dialogProps: { width: '820px' },
formSchema: {
showDefaultActions: false,
labelWidth: '105px',
schema: [
{ component: 'Input', prop: 'id', label: 'ID', show: false, ifDetail: true, colSpan: 24 },
{
component: 'Input',
prop: 'order_no',
label: '单号/条码',
componentProps: { placeholder: '请输入单号或条码', maxlength: 100 },
rules: [{ required: true, message: '请输入单号或条码' }],
colSpan: 12
},
{
component: 'Input',
prop: 'user_code',
label: '提报人工号',
componentProps: { placeholder: '请输入提报人工号', maxlength: 50 },
colSpan: 12
},
{
component: 'Input',
prop: 'reporter',
label: '提报人',
componentProps: { placeholder: '请输入提报人', maxlength: 100 },
colSpan: 12
},
{
component: 'Input',
prop: 'type_code',
label: '处理类型编码',
componentProps: { placeholder: '例如 defective_repair', maxlength: 64 },
rules: [{ required: true, message: '请输入处理类型编码' }],
colSpan: 12
},
{
component: 'Input',
prop: 'type_name',
label: '处理类型',
componentProps: { placeholder: '例如 不良维修', maxlength: 100 },
rules: [{ required: true, message: '请输入处理类型' }],
colSpan: 12
},
{
component: 'Input',
prop: 'reason_code',
label: '异常原因编码',
componentProps: { placeholder: '例如 test_defect', maxlength: 64 },
rules: [{ required: true, message: '请输入异常原因编码' }],
colSpan: 12
},
{
component: 'Input',
prop: 'reason_name',
label: '异常原因',
componentProps: { placeholder: '例如 测试不良', maxlength: 100 },
rules: [{ required: true, message: '请输入异常原因' }],
colSpan: 12
},
{
component: 'Input',
prop: 'issue_code',
label: '具体问题编码',
componentProps: { placeholder: '可选', maxlength: 64 },
colSpan: 12
},
{
component: 'Input',
prop: 'issue_name',
label: '具体问题',
componentProps: { placeholder: '可选', maxlength: 100 },
colSpan: 12
},
{
component: 'Select',
prop: 'status',
label: '处理状态',
defaultValue: 0,
componentProps: { options: statusOptions },
colSpan: 12
},
{
component: 'Input',
prop: 'handler',
label: '处理人',
componentProps: { placeholder: '请输入处理人', maxlength: 100 },
colSpan: 12
},
{
component: 'Upload',
prop: 'images',
label: '现场图片',
componentProps: { listType: 'picture-card' },
ifDetail: true,
colSpan: 24
},
{
component: 'Input',
prop: 'remark',
label: '补充说明',
componentProps: { type: 'textarea', rows: 3, placeholder: '请输入现场情况或处理说明' },
colSpan: 24
}
]
}
})
@@ -0,0 +1,22 @@
<template>
<div class="exception-setting-page art-full-height">
<BasicCrud />
</div>
</template>
<script setup lang="tsx">
import { useCrud } from '@/components/crud'
import { crudSchema } from './schemas'
defineOptions({ name: 'ProduceExceptionSettingList' })
const [BasicCrud] = useCrud({
...crudSchema(),
})
</script>
<style scoped>
.exception-setting-page {
height: 100%;
}
</style>
@@ -0,0 +1,100 @@
import { CrudSchema } from '@/components/crud'
import { ProduceExceptionSettingService } from '@/apps/produce/api'
const settingTypeOptions = [
{ label: '处理类型', value: 'type' },
{ label: '异常原因', value: 'reason' },
{ label: '具体问题', value: 'issue' },
]
export const crudSchema = (): CrudSchema => ({
api: ProduceExceptionSettingService.list,
dialogTitle: '异常设置',
crudApi: {
add: ProduceExceptionSettingService.create,
edit: ProduceExceptionSettingService.update,
remove: ProduceExceptionSettingService.remove,
view: ProduceExceptionSettingService.get,
},
useCrud: true,
hasAdd: true,
hasEdit: true,
hasRemove: true,
hasView: true,
addAuth: 'produce:exception-setting:create',
editAuth: 'produce:exception-setting:update',
removeAuth: 'produce:exception-setting:delete',
viewAuth: 'produce:exception-setting:read',
rowKey: 'id',
searchFormSchema: {
labelWidth: '90px',
schema: [
{
prop: 'keyword', label: '关键字', component: 'Input',
componentProps: { clearable: true, placeholder: '编码、名称或备注' }, colSpan: 8,
},
{
prop: 'setting_type', label: '设置层级', component: 'Select',
componentProps: { clearable: true, placeholder: '请选择设置层级', options: settingTypeOptions }, colSpan: 8,
},
{
prop: 'is_enabled', label: '启用状态', component: 'Select',
componentProps: {
clearable: true, placeholder: '请选择启用状态',
options: [{ label: '启用', value: 1 }, { label: '停用', value: 0 }],
}, colSpan: 8,
},
],
},
columns: [
{ type: 'selection' },
{ type: 'index', label: '序号', width: 70 },
{ prop: 'setting_type', label: '设置层级', minWidth: 100, formatter: (row: { setting_type?: string }) => settingTypeOptions.find((item) => item.value === row.setting_type)?.label || row.setting_type || '' },
{ prop: 'code', label: '编码', minWidth: 150 },
{ prop: 'name', label: '名称', minWidth: 160 },
{ prop: 'parent_name', label: '所属上级', minWidth: 140 },
{ prop: 'sort', label: '排序', width: 90 },
{ prop: 'is_enabled', label: '状态', width: 90, formatter: (row: any) => row.is_enabled === 0 ? '停用' : '启用' },
{ prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
{ prop: 'created_name', label: '创建人', minWidth: 110 },
],
dialogProps: { width: '700px' },
formSchema: {
showDefaultActions: false,
labelWidth: '105px',
schema: [
{ component: 'Input', prop: 'id', label: 'ID', show: false, ifDetail: true, colSpan: 24 },
{
component: 'Select', prop: 'setting_type', label: '设置层级',
componentProps: { placeholder: '请选择设置层级', options: settingTypeOptions },
rules: [{ required: true, message: '请选择设置层级' }], colSpan: 12,
},
{
component: 'ApiSelect', prop: 'parent_id', label: '所属上级',
componentProps: {
api: '/produce/exception-setting', requestMethod: 'get',
params: { format: 'select', NE_setting_type: 'issue' }, clearable: true, placeholder: '处理类型无需选择上级',
}, colSpan: 12,
},
{
component: 'Input', prop: 'code', label: '设置编码',
componentProps: { placeholder: '请输入唯一编码', maxlength: 64 },
rules: [{ required: true, message: '请输入设置编码' }], colSpan: 12,
},
{
component: 'Input', prop: 'name', label: '设置名称',
componentProps: { placeholder: '请输入显示名称', maxlength: 100 },
rules: [{ required: true, message: '请输入设置名称' }], colSpan: 12,
},
{ component: 'InputNumber', prop: 'sort', label: '排序', defaultValue: 0, componentProps: { min: 0 }, colSpan: 12 },
{
component: 'RadioGroup', prop: 'is_enabled', label: '是否启用', defaultValue: 1,
componentProps: { options: [{ label: '启用', value: 1 }, { label: '停用', value: 0 }] }, colSpan: 12,
},
{
component: 'Input', prop: 'remark', label: '备注',
componentProps: { type: 'textarea', rows: 3, placeholder: '请输入备注' }, colSpan: 24,
},
],
},
})
@@ -4,7 +4,7 @@ import { ProduceExceptionRecordService } from '@/apps/produce/api'
const statusOptions = [
{ label: '待处理', value: 0 },
{ label: '处理中', value: 1 },
{ label: '已处理', value: 2 },
{ label: '已处理', value: 2 }
]
export const crudSchema = (): CrudSchema => ({
@@ -14,7 +14,7 @@ export const crudSchema = (): CrudSchema => ({
add: ProduceExceptionRecordService.create,
edit: ProduceExceptionRecordService.update,
remove: ProduceExceptionRecordService.remove,
view: ProduceExceptionRecordService.get,
view: ProduceExceptionRecordService.get
},
useCrud: true,
hasAdd: true,
@@ -34,21 +34,21 @@ export const crudSchema = (): CrudSchema => ({
label: '关键字',
component: 'Input',
componentProps: { clearable: true, placeholder: '单号、提报人、处理类型、原因或具体问题' },
colSpan: 8,
colSpan: 8
},
{
prop: 'type_code',
label: '处理类型',
component: 'Input',
componentProps: { clearable: true, placeholder: '请输入处理类型编码' },
colSpan: 8,
colSpan: 8
},
{
prop: 'status',
label: '处理状态',
component: 'Select',
componentProps: { clearable: true, placeholder: '请选择处理状态', options: statusOptions },
colSpan: 8,
colSpan: 8
},
{
prop: 'reported_at_range',
@@ -59,11 +59,11 @@ export const crudSchema = (): CrudSchema => ({
clearable: true,
valueFormat: 'YYYY-MM-DD',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
endPlaceholder: '结束日期'
},
colSpan: 12,
},
],
colSpan: 12
}
]
},
columns: [
{ type: 'selection' },
@@ -76,7 +76,7 @@ export const crudSchema = (): CrudSchema => ({
{ prop: 'status_name', label: '处理状态', minWidth: 100 },
{ prop: 'handler', label: '处理人', minWidth: 110 },
{ prop: 'reported_date', label: '提报时间', minWidth: 170 },
{ prop: 'handled_date', label: '处理时间', minWidth: 170 },
{ prop: 'handled_date', label: '处理时间', minWidth: 170 }
],
dialogProps: { width: '820px' },
formSchema: {
@@ -85,43 +85,103 @@ export const crudSchema = (): CrudSchema => ({
schema: [
{ component: 'Input', prop: 'id', label: 'ID', show: false, ifDetail: true, colSpan: 24 },
{
component: 'Input', prop: 'order_no', label: '单号/条码',
component: 'Input',
prop: 'order_no',
label: '单号/条码',
componentProps: { placeholder: '请输入单号或条码', maxlength: 100 },
rules: [{ required: true, message: '请输入单号或条码' }], colSpan: 12,
rules: [{ required: true, message: '请输入单号或条码' }],
colSpan: 12
},
{ component: 'Input', prop: 'user_code', label: '提报人工号', componentProps: { placeholder: '请输入提报人工号', maxlength: 50 }, colSpan: 12 },
{ component: 'Input', prop: 'reporter', label: '提报人', componentProps: { placeholder: '请输入提报人', maxlength: 100 }, colSpan: 12 },
{
component: 'Input', prop: 'type_code', label: '处理类型编码',
component: 'Input',
prop: 'user_code',
label: '提报人工号',
componentProps: { placeholder: '请输入提报人工号', maxlength: 50 },
colSpan: 12
},
{
component: 'Input',
prop: 'reporter',
label: '提报人',
componentProps: { placeholder: '请输入提报人', maxlength: 100 },
colSpan: 12
},
{
component: 'Input',
prop: 'type_code',
label: '处理类型编码',
componentProps: { placeholder: '例如 defective_repair', maxlength: 64 },
rules: [{ required: true, message: '请输入处理类型编码' }], colSpan: 12,
rules: [{ required: true, message: '请输入处理类型编码' }],
colSpan: 12
},
{
component: 'Input', prop: 'type_name', label: '处理类型',
component: 'Input',
prop: 'type_name',
label: '处理类型',
componentProps: { placeholder: '例如 不良维修', maxlength: 100 },
rules: [{ required: true, message: '请输入处理类型' }], colSpan: 12,
rules: [{ required: true, message: '请输入处理类型' }],
colSpan: 12
},
{
component: 'Input', prop: 'reason_code', label: '异常原因编码',
component: 'Input',
prop: 'reason_code',
label: '异常原因编码',
componentProps: { placeholder: '例如 test_defect', maxlength: 64 },
rules: [{ required: true, message: '请输入异常原因编码' }], colSpan: 12,
rules: [{ required: true, message: '请输入异常原因编码' }],
colSpan: 12
},
{
component: 'Input', prop: 'reason_name', label: '异常原因',
component: 'Input',
prop: 'reason_name',
label: '异常原因',
componentProps: { placeholder: '例如 测试不良', maxlength: 100 },
rules: [{ required: true, message: '请输入异常原因' }], colSpan: 12,
},
{ component: 'Input', prop: 'issue_code', label: '具体问题编码', componentProps: { placeholder: '可选', maxlength: 64 }, colSpan: 12 },
{ component: 'Input', prop: 'issue_name', label: '具体问题', componentProps: { placeholder: '可选', maxlength: 100 }, colSpan: 12 },
{ component: 'Select', prop: 'status', label: '处理状态', defaultValue: 0, componentProps: { options: statusOptions }, colSpan: 12 },
{ component: 'Input', prop: 'handler', label: '处理人', componentProps: { placeholder: '请输入处理人', maxlength: 100 }, colSpan: 12 },
{
component: 'Upload', prop: 'images', label: '现场图片', componentProps: { listType: 'picture-card' }, ifDetail: true, colSpan: 24,
rules: [{ required: true, message: '请输入异常原因' }],
colSpan: 12
},
{
component: 'Input', prop: 'remark', label: '补充说明',
componentProps: { type: 'textarea', rows: 3, placeholder: '请输入现场情况或处理说明' }, colSpan: 24,
component: 'Input',
prop: 'issue_code',
label: '具体问题编码',
componentProps: { placeholder: '可选', maxlength: 64 },
colSpan: 12
},
],
},
{
component: 'Input',
prop: 'issue_name',
label: '具体问题',
componentProps: { placeholder: '可选', maxlength: 100 },
colSpan: 12
},
{
component: 'Select',
prop: 'status',
label: '处理状态',
defaultValue: 0,
componentProps: { options: statusOptions },
colSpan: 12
},
{
component: 'Input',
prop: 'handler',
label: '处理人',
componentProps: { placeholder: '请输入处理人', maxlength: 100 },
colSpan: 12
},
{
component: 'Upload',
prop: 'images',
label: '现场图片',
componentProps: { listType: 'picture-card' },
ifDetail: true,
colSpan: 24
},
{
component: 'Input',
prop: 'remark',
label: '补充说明',
componentProps: { type: 'textarea', rows: 3, placeholder: '请输入现场情况或处理说明' },
colSpan: 24
}
]
}
})
@@ -25,7 +25,7 @@ export const crudSchema = (): CrudSchema => ({
labelWidth: '90px',
schema: [
{ prop: 'keyword', label: '关键字', component: 'Input', componentProps: { clearable: true, placeholder: '商品编码或商品名称' }, colSpan: 8 },
{ prop: 'category_id', label: '物料分类', component: 'ApiSelect', componentProps: { api: ProduceMaterialService.categoryOptions, labelField: 'category_name', valueField: 'id', clearable: true, placeholder: '请选择分类' }, colSpan: 8 },
{ prop: 'category_id', label: '物料分类', component: 'ApiSelect', componentProps: { api: ProduceMaterialService.categoryOptions, labelField: 'label', valueField: 'value', clearable: true, placeholder: '请选择分类' }, colSpan: 8 },
],
},
columns: [
@@ -42,7 +42,7 @@ export const crudSchema = (): CrudSchema => ({
labelWidth: '90px',
schema: [
{ component: 'Input', show: false, ifDetail: true, colSpan: 24, prop: 'id', label: 'ID' },
{ component: 'ApiSelect', prop: 'category_id', label: '物料分类', componentProps: { api: ProduceMaterialService.categoryOptions, labelField: 'category_name', valueField: 'id', clearable: true, placeholder: '请选择分类' }, rules: [{ required: true, message: '请选择物料分类' }], colSpan: 12 },
{ component: 'ApiSelect', prop: 'category_id', label: '物料分类', componentProps: { api: ProduceMaterialService.categoryOptions, labelField: 'label', valueField: 'value', clearable: true, placeholder: '请选择分类' }, rules: [{ required: true, message: '请选择物料分类' }], colSpan: 12 },
{ component: 'Input', prop: 'product_code', label: '商品编码', componentProps: { maxlength: 100, placeholder: '请输入或扫描商品编码' }, rules: [{ required: true, message: '请输入商品编码' }], colSpan: 12 },
{ component: 'Input', prop: 'product_name', label: '商品名称', componentProps: { maxlength: 200, placeholder: '请输入商品名称' }, rules: [{ required: true, message: '请输入商品名称' }], colSpan: 24 },
],
@@ -297,7 +297,6 @@ export const crudSchema = (): CrudSchema => ({
prop: "id",
label: "ID",
show: false,
ifDetail: true,
colSpan: 24,
},
{
@@ -56,12 +56,30 @@ export interface ProduceRepairPart {
export interface ProduceScanRecord {
id: string | number
process_id?: string | number | null
step_id?: string | number | null
process_name?: string
step_name?: string
operator?: string
user_code?: string
}
export type ProduceOperationFaultStep = string | number
export type ProduceResponsibleOperator = string | number
export interface ProduceRepairRecordPayload {
exception_id: string | number
user_code: string
repair_result: string
images?: string[]
category_ids?: Array<string | number>
parts?: ProduceRepairPart[]
operation_fault_steps?: ProduceOperationFaultStep[]
responsible_operators?: ProduceResponsibleOperator[]
remark?: string
}
export function getExceptionSettings() {
return request.get<ProduceExceptionSetting[]>('/produce/exception/settings', {}, { showErrorMessage: false })
}
@@ -78,6 +96,10 @@ export function checkExceptionOrder(orderNo: string) {
return request.get<Record<string, any>>('/produce/exception/order-check', { order_no: orderNo.trim() }, { showErrorMessage: false })
}
export function checkMemberUser(userCode: string) {
return request.get<{ id: number; username: string; nickname: string }>('/produce/member-user/check', { user_code: userCode.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,
@@ -127,7 +149,22 @@ export function lookupMaterial(productCode: string) {
}>('/produce/material/lookup', { product_code: productCode.trim() }, { showErrorMessage: false })
}
export function submitRepairRecord(data: Record<string, any>) {
export function searchMaterials(productCode = '', productName = '', categoryId: string | number = '', page = 1, limit = 10) {
return request.get<{
items: Array<{
id: string | number
category_id: string | number
category_name: string
product_code: string
product_name: string
}>
total: number
page: number
limit: number
}>('/produce/material/options', { product_code: productCode.trim(), product_name: productName.trim(), category_id: categoryId, page, limit }, { showErrorMessage: false })
}
export function submitRepairRecord(data: ProduceRepairRecordPayload) {
return request.post<{ code: number; msg: string; data?: { id: string | number; repair_no: string } }>('/produce/repair-record/record', data, {
showErrorMessage: false,
returnFullResponse: true,
@@ -9,6 +9,7 @@ export interface ProduceQueryRecord {
order_no?: string
product_code?: string
process_name?: string
step_name?: string
exception_reason?: string
exception_content?: string
material_category?: string
@@ -66,7 +66,7 @@
</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 operator-panel"><h2>提报信息</h2><div><span>用户编码</span><el-input v-model="userCode" size="small" clearable placeholder="请输入用户编码" @blur="checkUserCode" @keyup.enter="checkUserCode" /></div><div><span>提报人</span><strong>{{ userName || '-' }}</strong></div><div><span>提交时间</span><strong>{{ nowText }}</strong></div><p v-if="userCodeError" class="field-error">{{ userCodeError }}</p></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>
@@ -79,10 +79,13 @@ 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'
import { checkExceptionOrder, checkMemberUser, 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 userCode = ref('')
const userName = ref('')
const userCodeError = ref('')
const form = ref({ orderNo: '', type: '', reason: '', issue: '', remark: '' })
const settings = ref<ProduceExceptionSetting[]>([])
const recentExceptions = ref<ProduceExceptionRecord[]>([])
@@ -109,6 +112,11 @@ const loadData = async () => {
if (!form.value.type) selectType(typeOptions.value[0]?.value || '')
} catch { /* 请求层已提示错误 */ }
}
const checkUserCode = async () => {
const value = userCode.value.trim()
if (!value) { userCodeError.value = '请输入用户编码'; userName.value = ''; return false }
try { const result = await checkMemberUser(value); userName.value = result?.nickname || result?.username || ''; userCodeError.value = ''; return true } catch (error: any) { userCodeError.value = error?.message || '用户编码不存在'; userName.value = ''; return false }
}
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 () => {
@@ -122,17 +130,17 @@ const resetForm = () => { form.value = { orderNo: '', type: typeOptions.value[0]
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 }
if (!await checkUserCode()) { ElMessage.warning(userCodeError.value); 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() })
const response = await submitExceptionRecord({ order_no: form.value.orderNo.trim(), user_code: userCode.value.trim(), 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() })
onMounted(() => { updateNow(); userCode.value = currentUser.value.code; userName.value = currentUser.value.name; void loadData() })
</script>
<style scoped>
@@ -147,4 +155,10 @@ onMounted(() => { updateNow(); void loadData() })
.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; } }
.operator-panel > div:first-of-type { align-items: center; gap: 16px; }
.operator-panel > div:first-of-type > span { flex: 0 0 72px; white-space: nowrap; }
.operator-panel > div:first-of-type :deep(.el-input) { flex: 1; min-width: 0; }
.operator-panel > div:first-of-type :deep(.el-input__wrapper) { min-height: 40px; padding: 0 13px; border-radius: 9px; box-shadow: 0 0 0 1px #d8e2ef inset; }
.operator-panel > div:first-of-type :deep(.el-input__inner) { font-size: 15px; font-weight: 600; }
.operator-panel > div:not(:first-of-type) { min-height: 44px; align-items: center; }
</style>
@@ -39,43 +39,57 @@
<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>
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>用户编码</span><el-input v-model="userCode" size="small" clearable placeholder="请输入用户编码" @blur="checkUserCode" @keyup.enter="checkUserCode" /></div><div><span>姓名</span><strong>{{ userName || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div><p v-if="userCodeError" class="field-error">{{ userCodeError }}</p></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>
<el-dialog v-model="partDialogVisible" title="选择维修备件" width="760px" class="material-dialog" @closed="resetMaterialSearch"><div class="material-search"><el-input v-model="materialProductCode" clearable autofocus placeholder="商品编码" @keyup.enter="searchMaterialList" /><el-input v-model="materialProductName" clearable placeholder="商品名称" @keyup.enter="searchMaterialList" /><el-select v-model="materialCategoryId" clearable filterable placeholder="物料分类" @change="searchMaterialList"><el-option v-for="item in categories" :key="item.id" :label="item.category_name" :value="String(item.id)" /></el-select><el-button type="primary" :loading="materialLoading" @click="searchMaterialList">搜索</el-button></div><div class="material-hint">可按编码、名称或物料分类筛选,已添加的备件会自动标记。</div><el-table v-loading="materialLoading" :data="materialItems" border stripe class="material-table"><el-table-column prop="product_code" label="商品编码" min-width="180" /><el-table-column prop="product_name" label="商品名称" min-width="220" show-overflow-tooltip /><el-table-column prop="category_name" label="物料分类" min-width="140"><template #default="scope">{{ scope.row.category_name || '-' }}</template></el-table-column><el-table-column label="操作" width="100" fixed="right"><template #default="scope"><el-button link type="primary" :disabled="isPartAdded(scope.row.product_code)" @click="selectMaterial(scope.row)">{{ isPartAdded(scope.row.product_code) ? '已添加' : '选择' }}</el-button></template></el-table-column></el-table><el-empty v-if="!materialLoading && !materialItems.length" description="暂无匹配的备件" :image-size="72" /><div class="material-pagination"><el-pagination v-model:current-page="materialPage" v-model:page-size="materialLimit" layout="total, prev, pager, next" :total="materialTotal" :page-sizes="[10, 20, 50]" @current-change="searchMaterialList" @size-change="handleMaterialSizeChange" /></div><template #footer><el-button @click="partDialogVisible = false">关闭</el-button></template></el-dialog>
</div>
</template>
<script setup lang="ts">
import { CircleCheckFilled, Grid, EditPen, Tools, Box } from '@element-plus/icons-vue'
import { CircleCheckFilled, Grid, EditPen, Tools, Box, Search } 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'
import { checkMemberUser, getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, searchMaterials, submitRepairRecord, uploadProduceImage, type ProduceRepairTask, type ProduceScanRecord } 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 }
interface MaterialOption { id: string | number; product_code: string; product_name: string; category_id: string | number; category_name: 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 userCode = ref('')
const userName = ref('')
const userCodeError = ref('')
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 scanRecords = ref<ProduceScanRecord[]>([])
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 materialProductCode = ref('')
const materialProductName = ref('')
const materialCategoryId = ref<string | number>('')
const materialItems = ref<MaterialOption[]>([])
const materialPage = ref(1)
const materialLimit = ref(10)
const materialTotal = ref(0)
const materialLoading = ref(false)
const reasonText = computed(() => [task.value?.reason_name, task.value?.issue_name].filter(Boolean).join(' · ') || '-')
const checkUserCode = async () => {
const value = userCode.value.trim()
if (!value) { userCodeError.value = '请输入用户编码'; userName.value = ''; return false }
try { const result = await checkMemberUser(value); userName.value = result?.nickname || result?.username || ''; userCodeError.value = ''; return true } catch (error: any) { userCodeError.value = error?.message || '用户编码不存在'; userName.value = ''; return false }
}
const loadStatistics = async () => {
const data = await getRepairStatistics({ user_code: currentUser.value.code }).catch(() => null)
@@ -88,18 +102,38 @@ const loadStatistics = async () => {
}
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 openPartDialog = () => { materialProductCode.value = ''; materialProductName.value = ''; materialCategoryId.value = ''; materialPage.value = 1; partDialogVisible.value = true; void searchMaterialList() }
const searchMaterialList = async () => { materialLoading.value = true; try { const result = await searchMaterials(materialProductCode.value, materialProductName.value, materialCategoryId.value, materialPage.value, materialLimit.value); materialItems.value = Array.isArray(result?.items) ? result.items : []; materialTotal.value = Number(result?.total || 0) } finally { materialLoading.value = false } }
const handleMaterialSizeChange = (size: number) => { materialLimit.value = size; materialPage.value = 1; void searchMaterialList() }
const isPartAdded = (productCode: string) => form.value.parts.some((item: Part) => item.product_code === productCode)
const appendMaterial = async (material: MaterialOption) => { if (isPartAdded(material.product_code)) return ElMessage.warning('该物料已添加'); const part: Part = { id: `${Date.now()}-${material.product_code}`, product_code: material.product_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(); ElMessage.success('备件已添加') }
const selectMaterial = (material: MaterialOption) => { void appendMaterial(material) }
const resetMaterialSearch = () => { materialProductCode.value = ''; materialProductName.value = ''; materialCategoryId.value = ''; materialItems.value = []; materialPage.value = 1; materialTotal.value = 0 }
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 completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!await checkUserCode()) return ElMessage.warning(userCodeError.value); 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 selectedScanIds = scanRecords.value.filter(record => form.value.responsible_ids.includes(String(record.id))).map(record => record.id); const response = await submitRepairRecord({ exception_id: task.value.id, user_code: userCode.value.trim(), 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: selectedScanIds, responsible_operators: selectedScanIds, 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() } })
onMounted(() => { userCode.value = currentUser.value.code; userName.value = currentUser.value.name; void checkUserCode(); 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}}
.operator-panel > div:first-of-type { align-items: center; gap: 16px; }
.operator-panel > div:first-of-type > span { flex: 0 0 72px; white-space: nowrap; }
.operator-panel > div:first-of-type :deep(.el-input) { flex: 1; min-width: 0; }
.operator-panel > div:first-of-type :deep(.el-input__wrapper) { min-height: 40px; padding: 0 13px; border-radius: 9px; box-shadow: 0 0 0 1px #d8e2ef inset; }
.operator-panel > div:first-of-type :deep(.el-input__inner) { font-size: 15px; font-weight: 600; }
.operator-panel > div:not(:first-of-type) { min-height: 44px; align-items: center; }
.responsibility-grid .el-checkbox__label { gap: 10px; line-height: 1.25; }
.responsibility-grid .el-checkbox__label > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.responsibility-grid small { line-height: 1.35; }
.material-search { display: flex; gap: 12px; margin-bottom: 10px; }
.material-search .el-input { flex: 1; min-width: 0; }
.material-search .el-select { width: 170px; flex: none; }
.material-hint { margin-bottom: 12px; color: #8a94a6; font-size: 13px; }
.material-table { width: 100%; }
.material-pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>