Files
ctms/frontend/src/views/ia/MaterialEquipment.vue
T

613 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page page--flush">
<div v-if="study.currentStudy" class="unified-shell">
<section class="unified-section section--flush">
<div class="filter-row">
<el-form :inline="true" :model="filters">
<el-form-item label="设备名称">
<el-input v-model="filters.name" clearable placeholder="请输入" class="name-filter" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="openCreate">新建</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="ctms-table equipment-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="name" label="设备名称" />
<el-table-column prop="specModel" label="规格型号" />
<el-table-column prop="unit" label="单位" />
<el-table-column prop="brand" label="品牌" />
<el-table-column label="是否需要校准">
<template #default="{ row }">
{{ row.needCalibration ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">暂无设备数据</div>
</template>
</el-table>
</section>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<el-drawer
v-if="drawerVisible"
v-model="drawerVisible"
direction="rtl"
size="620px"
:close-on-click-modal="false"
:show-close="false"
class="equipment-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
<!-- 基本信息分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
基本信息
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="specModel">
<el-input v-model="form.specModel" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="产地" prop="origin">
<el-input v-model="form.origin" placeholder="请输入产地" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 资质文件分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
资质文件
</div>
<el-row :gutter="16">
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">生产许可证</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
<div v-if="!form.productionPermitFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.productionPermitFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">技术指标</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
<div v-if="!form.techIndexFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.techIndexFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 校准设置分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-calibration"></span>
校准设置
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否需要校准" prop="needCalibration">
<el-select v-model="form.needCalibration" placeholder="请选择" class="full-width">
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="form.needCalibration" :span="12">
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
<el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<div v-if="!form.needCalibration" class="calibration-hint">
<span class="hint-icon"></span>
<span>该设备无需定期校准</span>
</div>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
import {
createMaterialEquipment,
deleteMaterialEquipment,
listMaterialEquipments,
updateMaterialEquipment,
} from "../../api/materialEquipments";
import StateEmpty from "../../components/StateEmpty.vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
interface EquipmentRow {
id: string;
name: string;
specModel: string;
unit: string;
brand: string;
origin: string;
productionPermitFileName: string;
techIndexFileName: string;
needCalibration: boolean;
calibrationCycleDays: number | null;
}
type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const defaultForm: FormModel = {
name: "",
specModel: "",
unit: "",
brand: "",
origin: "",
productionPermitFileName: "",
techIndexFileName: "",
needCalibration: true,
calibrationCycleDays: 30,
};
const form = reactive<FormModel>({ ...defaultForm });
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
calibrationCycleDays: [
{
validator: (_rule, value, callback) => {
if (form.needCalibration && (!value || value < 1)) {
callback(new Error("请填写校准周期"));
return;
}
callback();
},
trigger: "change",
},
],
};
const loadRows = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) {
rows.value = [];
return;
}
loading.value = true;
try {
const { data } = (await listMaterialEquipments(studyId, {
name: filters.name.trim() || undefined,
limit: 500,
})) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map((item: any) => ({
id: item.id,
name: item.name || "",
specModel: item.spec_model || "",
unit: item.unit || "",
brand: item.brand || "",
origin: item.origin || "",
productionPermitFileName: item.production_permit_file_name || "",
techIndexFileName: item.tech_index_file_name || "",
needCalibration: !!item.need_calibration,
calibrationCycleDays: item.calibration_cycle_days ?? null,
}));
} catch (e: any) {
rows.value = [];
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const resetForm = () => {
Object.assign(form, defaultForm);
formRef.value?.clearValidate();
};
const openCreate = () => {
editingId.value = "";
resetForm();
drawerVisible.value = true;
};
const openEdit = (row: EquipmentRow) => {
editingId.value = row.id;
Object.assign(form, {
name: row.name,
specModel: row.specModel,
unit: row.unit,
brand: row.brand,
origin: row.origin,
productionPermitFileName: row.productionPermitFileName,
techIndexFileName: row.techIndexFileName,
needCalibration: row.needCalibration,
calibrationCycleDays: row.calibrationCycleDays,
});
formRef.value?.clearValidate();
drawerVisible.value = true;
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await formRef.value?.validate().catch(() => false);
if (!ok) return;
const payload = {
name: form.name.trim(),
spec_model: form.specModel.trim(),
unit: form.unit.trim() || null,
brand: form.brand.trim(),
origin: form.origin.trim() || null,
production_permit_file_name: form.productionPermitFileName || null,
tech_index_file_name: form.techIndexFileName || null,
need_calibration: !!form.needCalibration,
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
};
try {
if (editingId.value) {
await updateMaterialEquipment(studyId, editingId.value, payload);
} else {
await createMaterialEquipment(studyId, payload);
}
await loadRows();
drawerVisible.value = false;
ElMessage.success("保存成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
}
};
const removeRow = async (row: EquipmentRow) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
if (!ok) return;
try {
await deleteMaterialEquipment(studyId, row.id);
await loadRows();
ElMessage.success("删除成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.deleteFailed);
}
};
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
form[field] = file.name;
};
const onProductionPermitChange = (file: UploadFile) => {
handleUploadChange(file, "productionPermitFileName");
};
const onTechIndexChange = (file: UploadFile) => {
handleUploadChange(file, "techIndexFileName");
};
const handleSearch = () => {
loadRows();
};
const resetFilters = () => {
filters.name = "";
loadRows();
};
watch(
() => study.currentStudy?.id,
() => {
filters.name = "";
loadRows();
drawerVisible.value = false;
},
{ immediate: true }
);
watch(
() => form.needCalibration,
(need) => {
if (!need) form.calibrationCycleDays = null;
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.name-filter {
width: 260px;
}
/* ========== 抽屉头部 ========== */
.editor-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.editor-title {
font-size: 20px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
.editor-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 400;
}
/* ========== 表单整体 ========== */
.equipment-form {
padding: 4px 4px 0 0;
}
/* ========== 分组卡片 ========== */
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.form-group:hover {
border-color: #d0dced;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* 基本信息 - 蓝色 */
.group-dot-basic {
background: #3b82f6;
}
/* 资质文件 - 琥珀色 */
.group-dot-file {
background: #f0ad2c;
}
/* 校准设置 - 绿色 */
.group-dot-calibration {
background: #22c55e;
}
/* ========== 上传卡片 ========== */
.upload-card {
border: 1px dashed #d0dced;
border-radius: 8px;
padding: 12px 14px;
background: #ffffff;
transition: all 0.2s ease;
min-height: 60px;
}
.upload-card:hover {
border-color: var(--ctms-primary);
background: #f8faff;
}
.upload-card-label {
font-size: 12px;
font-weight: 600;
color: #4a6283;
margin-bottom: 8px;
}
.upload-trigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 0;
}
.upload-icon {
font-size: 18px;
line-height: 1;
}
.upload-text {
font-size: 13px;
color: var(--ctms-text-secondary);
transition: color 0.2s ease;
}
.upload-trigger:hover .upload-text {
color: var(--ctms-primary);
}
.upload-result {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.upload-result-icon {
font-size: 14px;
line-height: 1;
}
.upload-result-name {
font-size: 12px;
color: var(--ctms-text-regular);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* ========== 校准提示 ========== */
.calibration-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 13px;
color: #166534;
margin-bottom: 8px;
}
.hint-icon {
font-size: 14px;
line-height: 1;
}
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ========== 表单元素微调 ========== */
.equipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.equipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
.equipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
/* ========== 表格居中 ========== */
.equipment-table :deep(th.el-table__cell .cell),
.equipment-table :deep(td.el-table__cell .cell) {
text-align: center;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>