Files
KCGL/inventory-web/src/views/material/list.vue
dxc 661ce4e5a0 fix: disable column hiding by permissions in material list view
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:54:18 +08:00

1088 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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="app-container">
<el-card shadow="never">
<div class="filter-wrapper">
<div class="filter-container">
<el-input
v-model="queryParams.keyword"
placeholder="请输入名称、俗名或规格"
style="width: 240px; margin-right: 10px;"
clearable
@input="handleInputSearch"
/>
<el-select
v-model="queryParams.company"
placeholder="所属公司"
clearable
filterable
default-first-option
style="width: 120px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.category"
placeholder="类别"
clearable
filterable
allow-create
default-first-option
style="width: 240px; margin-right: 10px;"
@change="handleQuery"
popper-class="long-dropdown"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.type"
placeholder="类型"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
popper-class="long-dropdown"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.isEnabled"
placeholder="状态"
clearable
style="width: 100px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
<el-button plain @click="resetQuery">重置</el-button>
</div>
<div class="right-toolbar">
<el-button type="success" plain @click="handleExport" :loading="exportLoading" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
</el-button>
<el-button v-if="userStore.hasPermission('material_list:operation')" type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button>
<el-tooltip content="刷新" placement="top">
<el-button circle :icon="Refresh" @click="getList" />
</el-tooltip>
<el-dropdown trigger="click" @command="handleSizeChange">
<el-button circle :icon="Rank" style="margin-left: 8px" title="表格密度" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="large">宽松 (默认)</el-dropdown-item>
<el-dropdown-item command="default">中等</el-dropdown-item>
<el-dropdown-item command="small">紧凑</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-popover placement="bottom" :width="150" trigger="click">
<template #reference>
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template>
<div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置
</div>
<el-checkbox v-model="columns.id.visible" label="ID" :disabled="!userStore.hasPermission(permissionMap.id)" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" :disabled="!userStore.hasPermission(permissionMap.companyName)" />
<el-checkbox v-model="columns.name.visible" label="名称" :disabled="!userStore.hasPermission(permissionMap.name)" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" :disabled="!userStore.hasPermission(permissionMap.commonName)" />
<el-checkbox v-model="columns.category.visible" label="类别" :disabled="!userStore.hasPermission(permissionMap.category)" />
<el-checkbox v-model="columns.type.visible" label="类型" :disabled="!userStore.hasPermission(permissionMap.type)" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" :disabled="!userStore.hasPermission(permissionMap.spec)" />
<el-checkbox v-model="columns.unit.visible" label="单位" :disabled="!userStore.hasPermission(permissionMap.unit)" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" :disabled="!userStore.hasPermission(permissionMap.inventory)" />
<el-checkbox v-model="columns.available.visible" label="可用数" :disabled="!userStore.hasPermission(permissionMap.available)" />
<el-checkbox v-model="columns.files.visible" label="资料" :disabled="!userStore.hasPermission(permissionMap.files)" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" :disabled="!userStore.hasPermission(permissionMap.isEnabled)" />
</div>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
:size="tableSize"
style="width: 100%; margin-top: 15px"
>
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip>
<template #default="scope">
<span>{{ scope.row.companyName || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip>
<template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.type || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.inventoryCount > 0 ? '#67C23A' : '#909399' }">
{{ row.inventoryCount }}
</span>
</template>
</el-table-column>
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : '#909399' }">
{{ row.availableCount }}
</span>
</template>
</el-table-column>
<el-table-column v-if="columns.files.visible" label="资料" min-width="140" align="center">
<template #default="{ row }">
<div style="display: flex; gap: 8px; justify-content: center;">
<div v-if="getImagesOnly(row.generalImage).length > 0" class="file-preview-cell">
<el-image
style="width: 32px; height: 32px; border-radius: 4px;"
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
/>
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
</div>
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
<template #reference>
<el-button link type="primary" :icon="Document" />
</template>
<div style="display: flex; flex-direction: column; gap: 5px;">
<div v-for="(link, idx) in row.generalManual" :key="idx">
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
说明书 {{idx+1}} <el-icon><Link /></el-icon>
</el-link>
<el-image v-else-if="isImageFile(link)"
style="width: 100px; height: 100px"
:src="getImageUrl(link)"
:preview-src-list="[getImageUrl(link)]"
fit="cover"
preview-teleported
/>
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.isEnabled"
:active-value="1"
:inactive-value="0"
:loading="scope.row.statusLoading"
:disabled="!userStore.hasPermission('material_list:operation')"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="150" fixed="right" align="center">
<template #default="scope">
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handlePageSizeChange"
@current-change="handlePageCurrentChange"
/>
</div>
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="700px"
append-to-body
@close="cancel"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
<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="commonName">
<el-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="所属公司" prop="companyName">
<el-autocomplete
v-model="form.companyName"
:fetch-suggestions="querySearchCompany"
placeholder="请输入公司名称"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类别" prop="category">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
v-model="tempCategoryPrefix"
:options="categoryTreeOptions"
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
placeholder="选择前缀层级"
filterable
clearable
style="width: 50%;"
/>
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
<el-input
v-model="tempCategorySuffix"
placeholder="填写具体名称"
clearable
style="width: 50%;"
/>
</div>
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
* 必须构成4层结构
</div>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="类型" prop="type">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder=": , , " />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="产品图" prop="generalImage">
<div class="upload-container">
<el-upload
v-model:file-list="fileListImage"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'generalImage')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'generalImage')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalImage')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input
v-model="imageExternalUrl"
placeholder="如有外部图片链接请在此输入"
style="margin-top: 8px;"
clearable
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="说明书" prop="generalManual">
<div class="upload-container">
<el-upload
v-model:file-list="fileListManual"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'generalManual')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalManual')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input
v-model="manualExternalUrl"
placeholder="如有外部说明书链接请在此输入"
style="margin-top: 8px;"
clearable
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-radio-group v-model="form.isEnabled">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确 定</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false"
/>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import {
listMaterialBase,
addMaterialBase,
updateMaterialBase,
delMaterialBase,
getMaterialBaseOptions,
exportAssetStatistics // 导入导出API
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
const userStore = useUserStore();
// --- 类型定义 ---
interface MaterialBaseVO {
id: number;
companyName: string;
name: string;
commonName?: string;
category: string;
type: string;
spec: string;
unit: string;
visibilityLevel: number;
generalManual: string[];
generalImage: string[];
isEnabled: number;
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
}
interface QueryParams {
pageNum: number;
pageSize: number;
keyword: string;
category: string;
type: string;
company: string;
isEnabled?: number;
}
interface CascaderOption {
value: string;
label: string;
children?: CascaderOption[];
}
// --- 响应式数据 ---
const loading = ref(false);
const exportLoading = ref(false); // 导出加载状态
const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]);
const submitLoading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
// 文件上传相关
const fileListImage = ref<any[]>([]);
const fileListManual = ref<any[]>([]);
const imageExternalUrl = ref('');
const manualExternalUrl = ref('');
const dialogVisibleImage = ref(false);
const dialogImageUrl = ref('');
const cameraDialogVisible = ref(false);
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
const columns = reactive({
id: { visible: false },
companyName: { visible: true },
name: { visible: true },
commonName: { visible: true },
category: { visible: true },
type: { visible: true },
spec: { visible: true },
unit: { visible: true },
inventory: { visible: true },
available: { visible: true },
files: { visible: true },
isEnabled: { visible: true }
});
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'material_list:id',
companyName: 'material_list:companyName',
name: 'material_list:name',
commonName: 'material_list:commonName',
category: 'material_list:category',
type: 'material_list:type',
spec: 'material_list:spec',
unit: 'material_list:unit',
inventory: 'material_list:inventoryCount', // 前端变量是 inventory数据库Code是 inventoryCount
available: 'material_list:availableCount', // 前端变量是 available数据库Code是 availableCount
files: 'material_list:files',
isEnabled: 'material_list:isEnabled'
};
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return;
}
// 普通用户:不再根据权限隐藏列,而是显示所有列,由后端控制字段值
// 所以这里不做任何操作保持columns的默认visibletrue
// 但是,我们也可以选择性地根据权限隐藏列,但用户要求列显示,所以不隐藏
// 因此,注释掉下面的代码
// Object.keys(columns).forEach(key => {
// const code = permissionMap[key];
// if (code) {
// columns[key].visible = !!userStore.hasPermission(code);
// }
// });
};
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({
pageNum: 1,
pageSize: 100,
keyword: '',
category: '',
type: '',
company: '',
isEnabled: undefined
});
// --- 弹窗与表单相关 ---
const dialog = reactive({
visible: false,
title: ''
});
const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
companyName: '',
name: '',
commonName: '',
category: '',
type: '',
spec: '',
unit: '',
visibilityLevel: 0,
generalManual: [] as string[],
generalImage: [] as string[],
isEnabled: 1
};
const form = ref({...initForm});
const validateCategoryLevel = (rule: any, value: any, callback: any) => {
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
if (!prefixStr && !suffixStr) {
callback(new Error('请填写或选择类别'));
return;
}
let fullPath = '';
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
else if (prefixStr) fullPath = prefixStr;
else fullPath = suffixStr;
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
if (levels !== 4) {
callback(new Error(`必须严格满足4层结构当前为 ${levels} 层`));
} else {
callback();
}
};
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
companyName: [{ required: true, message: '请输入公司名称', trigger: 'change' }],
category: [{ required: true, validator: validateCategoryLevel, trigger: 'change' }],
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
});
// --- 业务逻辑方法 ---
const buildCategoryTree = (categories: string[]): CascaderOption[] => {
const root: CascaderOption[] = [];
categories.forEach(cat => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children;
}
});
});
return root;
};
const getOptionsList = () => {
getMaterialBaseOptions().then((res: any) => {
if (res.code === 200) {
categoryOptions.value = res.data.categories || [];
typeOptions.value = res.data.types || [];
companyOptions.value = res.data.companies || [];
categoryTreeOptions.value = buildCategoryTree(categoryOptions.value);
}
}).catch(err => {
console.error("获取筛选项失败", err);
});
};
const querySearchCompany = (queryString: string, cb: any) => {
const results = queryString
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: companyOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const querySearchType = (queryString: string, cb: any) => {
const results = queryString
? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: typeOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const getList = () => {
loading.value = true;
listMaterialBase(queryParams)
.then((response: any) => {
if (response && response.data) {
tableData.value = response.data.items;
total.value = response.data.total;
} else {
tableData.value = [];
total.value = 0;
}
})
.catch((err) => {
console.error(err);
tableData.value = [];
})
.finally(() => {
loading.value = false;
});
};
// [修改] 导出处理函数:修正文件名格式
const handleExport = () => {
exportLoading.value = true;
const params = {
keyword: queryParams.keyword,
company: queryParams.company,
category: queryParams.category,
type: queryParams.type,
isEnabled: queryParams.isEnabled
};
exportAssetStatistics(params)
.then((response: any) => {
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 构造文件名库存统计_YYYYMMDD_HHMMSS
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
const filename = `库存统计_${year}${month}${day}_${hour}${minute}${second}.xlsx`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
})
.catch((err) => {
console.error("导出失败", err);
ElMessage.error('导出失败');
})
.finally(() => {
exportLoading.value = false;
});
};
let searchTimer: any = null;
const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
queryParams.pageNum = 1;
getList();
}, 500);
};
const handleQuery = () => {
queryParams.pageNum = 1;
getList();
};
const resetQuery = () => {
queryParams.keyword = '';
queryParams.category = '';
queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined;
handleQuery();
};
const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command;
};
const handlePageSizeChange = (val: number) => {
queryParams.pageSize = val;
queryParams.pageNum = 1;
getList();
};
const handlePageCurrentChange = (val: number) => {
queryParams.pageNum = val;
getList();
};
const handleAdd = () => {
resetForm();
dialog.title = '新增基础信息';
dialog.visible = true;
};
const handleEdit = (row: MaterialBaseVO) => {
resetForm();
dialog.title = '编辑基础信息';
dialog.visible = true;
nextTick(() => {
const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, data);
if (data.category) {
const parts = data.category.split('/');
if (parts.length > 0) {
tempCategorySuffix.value = parts.pop() || '';
tempCategoryPrefix.value = parts;
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = data.category;
}
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
}
const images = row.generalImage || [];
const manuals = row.generalManual || [];
const imgFiles = images.filter(u => !isExternalLink(u));
const imgLinks = images.filter(u => isExternalLink(u));
const manualFiles = manuals.filter(u => !isExternalLink(u));
const manualLinks = manuals.filter(u => isExternalLink(u));
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
imageExternalUrl.value = imgLinks.length > 0 ? imgLinks[0] : '';
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
manualExternalUrl.value = manualLinks.length > 0 ? manualLinks[0] : '';
});
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) {
ElMessage.error(`已存在名称为 "${name}" 的基础信息!`);
return true;
}
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec, category: '', type: '', company: '' });
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) {
ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`);
return true;
}
} catch (e) {
return false;
}
return false;
};
const submitForm = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) {
submitLoading.value = false;
return;
}
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item));
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value);
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
let fullCategory = '';
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
else fullCategory = prefixStr || suffixStr;
const payload = {
...form.value,
category: fullCategory,
generalImage: finalImageList,
generalManual: finalManualList
};
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(payload);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
getList();
getOptionsList();
} catch (error: any) {
ElMessage.error(error.msg || '保存失败');
} finally {
submitLoading.value = false;
}
}
});
};
const cancel = () => {
dialog.visible = false;
resetForm();
};
const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = [];
fileListManual.value = [];
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
imageExternalUrl.value = '';
manualExternalUrl.value = '';
if (formRef.value) formRef.value.resetFields();
};
const handleStatusChange = (row: MaterialBaseVO) => {
row.statusLoading = true;
const text = row.isEnabled === 1 ? "启用" : "停用";
const updateData = { id: row.id, isEnabled: row.isEnabled };
updateMaterialBase(updateData)
.then(() => ElMessage.success(`已${text} "${row.name}"`))
.catch(() => { row.isEnabled = row.isEnabled === 1 ? 0 : 1; })
.finally(() => { row.statusLoading = false; });
};
const handleDelete = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
`是否确认删除名称为 "${row.name}" 的数据项?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(() => {
delMaterialBase(row.id).then(() => {
ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList();
getOptionsList();
});
}).catch(() => {});
};
// --- 文件上传辅助函数 ---
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/PDF');
return false;
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('文件不能超过 10MB');
return false;
}
return true;
}
const customUpload = async (options: any, targetField: 'generalImage' | 'generalManual') => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form.value[targetField].push(newUrl)
ElMessage.success('上传成功')
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败');
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误');
onError(e)
}
}
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
try {
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) {
const filename = urlToRemove.split('/').pop();
if (filename) await deleteFile(filename)
}
ElMessage.success('已从列表移除')
} catch (e) { console.error(e) }
}
const handlePreviewPicture = (uploadFile: any) => {
dialogImageUrl.value = uploadFile.url!;
dialogVisibleImage.value = true
}
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
currentCameraField.value = field;
cameraDialogVisible.value = true;
}
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
return;
}
const formData = new FormData();
formData.append('file', file);
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
try {
const res: any = await uploadFile(formData);
if (res.code === 200) {
const newUrl = res.data.url;
const field = currentCameraField.value;
form.value[field].push(newUrl);
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
if (field === 'generalImage') {
fileListImage.value.push(fileObj);
} else {
fileListManual.value.push(fileObj);
}
ElMessage.success('拍照上传成功');
cameraDialogVisible.value = false;
} else {
ElMessage.error(res.msg || '上传失败');
}
} catch (e) {
ElMessage.error('上传过程中发生异常');
} finally {
loadingInstance.close();
}
};
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions();
getList();
getOptionsList();
});
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.right-toolbar {
display: flex;
align-items: center;
}
.column-setting-list {
display: flex;
flex-direction: column;
}
.pagination-container {
margin-top: 15px;
display: flex;
justify-content: flex-start;
}
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
</style>
<style>
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
}
</style>