feat(device): 新增设备管理模块全栈实现并拆分菜单 - 客户设备台账(customer_equipment)与智电设备型号目录(smart_device)CRUD,JWT+RBAC鉴权、参数绑定防SQL注入;设备监控改目录挂台账(/device/equipment),新建基础数据目录挂型号(/base/smart-device);前端防XSS样式对齐客户模块;远程库菜单迁移20261007_device_menu_split_fix.sql(id 58-60)

This commit is contained in:
wk committed 2026-10-07 08:58:34 +08:00
1 parent 91aa3c06f0
commit 09f7345e8a
18 files changed
+2482 -7

No files matched your search

+921
View File
@@ -0,0 +1,921 @@
<template>
<div class="device-equipment">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>设备监控</span>
<span class="sep">/</span>
<span class="cur">设备管理台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">设备管理台账</span>
<span class="sub-title">EQUIPMENT LEDGER</span>
</div>
<p class="desc">统一维护客户侧智电改造设备(配电间/井/箱)档案,支持按客户、建筑、类型快速检索。</p>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">所属建筑</label>
<a-input
v-model:value="searchForm.building"
placeholder="建筑名称"
allow-clear
@pressEnter="handleSearch"
/>
</div>
<div class="filter-item">
<label class="filter-label">设备类型</label>
<a-select v-model:value="searchForm.equipType" placeholder="全部类型" allow-clear>
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="设备编码 / 名称 / 位置"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">设备台账清单</span>
<span class="count">{{ total }} 台</span>
</div>
<div class="list-actions">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增设备
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'equipmentNo'">
<span class="mono">{{ record.equipmentNo || '-' }}</span>
</template>
<template v-else-if="column.key === 'equipmentName'">
<span class="equip-name">{{ record.equipmentName }}</span>
</template>
<template v-else-if="column.key === 'equipType'">
<span v-if="record.equipType" class="type-chip">{{ record.equipType }}</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ customerNameMap[record.customerId] || '-' }}
</template>
<template v-else-if="column.key === 'photo'">
<a-image
v-if="record.photo"
:src="record.photo"
:width="44"
:height="44"
style="border-radius: 6px"
:preview="{ mask: false }"
/>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm
title="确定要删除该设备吗?"
ok-text="确定"
cancel-text="取消"
ok-button-props="{ danger: true }"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无设备数据" />
</template>
</a-table>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="760px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<!-- 设备编号信息条(编辑态) -->
<div v-if="isEdit && editId" class="no-bar">
<div class="no-item">
<span class="no-label">设备编码</span>
<span class="no-value mono">{{ editNo || '-' }}</span>
</div>
</div>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 17 }"
>
<!-- SECTION 01 设备主体信息 -->
<div class="form-section">
<div class="section-head">
<span class="section-no">01</span>
<div class="section-text">
<span class="section-name">设备主体信息</span>
<span class="section-sub">Equipment Identity</span>
</div>
</div>
<a-form-item label="设备名称" name="equipmentName">
<a-input v-model:value="formData.equipmentName" placeholder="请输入设备名称" maxlength="128" />
</a-form-item>
<a-form-item label="设备类型" name="equipType">
<a-select
v-model:value="formData.equipType"
placeholder="请选择设备类型"
allow-clear
style="width: 100%"
>
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
placeholder="请选择所属客户"
:options="customerOptions"
show-search
option-filter-prop="label"
/>
</a-form-item>
</div>
<!-- SECTION 02 位置信息 -->
<div class="form-section">
<div class="section-head">
<span class="section-no">02</span>
<div class="section-text">
<span class="section-name">位置信息</span>
<span class="section-sub">Location</span>
</div>
</div>
<a-form-item label="所属建筑" name="building">
<a-input v-model:value="formData.building" placeholder="请输入所属建筑" maxlength="128" />
</a-form-item>
<a-form-item label="所在楼层" name="floor">
<a-input v-model:value="formData.floor" placeholder="请输入所在楼层" maxlength="64" />
</a-form-item>
<a-form-item label="具体位置" name="location">
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
</a-form-item>
</div>
<!-- SECTION 03 照片与备注 -->
<div class="form-section">
<div class="section-head">
<span class="section-no">03</span>
<div class="section-text">
<span class="section-name">照片与备注</span>
<span class="section-sub">Photo &amp; Remark</span>
</div>
</div>
<a-form-item label="设备照片" name="photo">
<div class="photo-field">
<div class="photo-preview">
<img v-if="photoPreview" :src="photoPreview" class="photo-thumb" alt="设备照片" />
<span v-else class="photo-empty">未上传</span>
</div>
<a-upload
:show-upload-list="false"
:custom-request="handlePhotoUpload"
:before-upload="beforePhotoUpload"
accept="image/*"
:disabled="photoUploading"
>
<a-button :loading="photoUploading">
{{ formData.photo ? '替换' : '上传' }}
</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" maxlength="256" />
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import {
customerEquipmentApi,
type CustomerEquipment,
type CustomerEquipmentQuery,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { uploadApi } from '@/api/upload';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// 设备类型字典(配电间/配电井/配电箱)
const equipTypeOptions = ['配电间', '配电井', '配电箱'];
const loading = ref(false);
const dataSource = ref<CustomerEquipment[]>([]);
const total = ref(0);
const editNo = ref<string>('');
const searchForm = reactive<CustomerEquipmentQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
building: '',
equipType: undefined as any,
});
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const customerNameMap = reactive<Record<number, string>>({});
const photoUploading = ref(false);
const photoPreview = ref<string>('');
const formData = reactive<Partial<CustomerEquipment>>({
equipmentName: '',
equipType: undefined,
customerId: undefined,
building: '',
floor: '',
location: '',
photo: '',
remark: '',
});
const formRules = {
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
};
const columns = [
{ title: '设备编码', dataIndex: 'equipmentNo', key: 'equipmentNo', ellipsis: true, width: 150 },
{ title: '设备名称', dataIndex: 'equipmentName', key: 'equipmentName', ellipsis: true },
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '所属建筑', dataIndex: 'building', key: 'building', ellipsis: true, width: 140 },
{ title: '所在楼层', dataIndex: 'floor', key: 'floor', ellipsis: true, width: 100 },
{ title: '具体位置', dataIndex: 'location', key: 'location', ellipsis: true },
{ title: '照片', dataIndex: 'photo', key: 'photo', align: 'center' as const, width: 70 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await customerEquipmentApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取设备列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
list.forEach((item) => {
customerNameMap[item.id] = `${item.customerNo} ${item.customerName}`;
});
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.building = '';
searchForm.equipType = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 照片上传 ====================
function beforePhotoUpload(file: File) {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('请上传图片文件');
return false;
}
if (file.size > 10 * 1024 * 1024) {
message.error('图片大小不能超过 10MB');
return false;
}
return true;
}
async function handlePhotoUpload(options: any) {
const { file, onSuccess, onError } = options;
const f = file as File;
if (!beforePhotoUpload(f)) {
onError?.(new Error('文件校验失败'));
return;
}
photoUploading.value = true;
try {
const res: any = await uploadApi.uploadImage(f);
if (res.code === 200 && res.data) {
formData.photo = res.data.key;
photoPreview.value = res.data.url;
onSuccess?.(res.data);
message.success('上传成功');
} else {
throw new Error(res.message || '上传失败');
}
} catch (e: any) {
message.error(e?.message || '图片上传失败');
onError?.(e);
} finally {
photoUploading.value = false;
}
}
// ==================== 新增/编辑 ====================
function handleAdd() {
isEdit.value = false;
editId.value = null;
editNo.value = '';
modalTitle.value = '新增设备';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: CustomerEquipment) {
isEdit.value = true;
editId.value = record.id;
editNo.value = record.equipmentNo;
modalTitle.value = '编辑设备';
resetForm();
formData.equipmentName = record.equipmentName;
formData.equipType = record.equipType || undefined;
formData.customerId = record.customerId;
formData.building = record.building || '';
formData.floor = record.floor || '';
formData.location = record.location || '';
formData.photo = record.photo || '';
formData.remark = record.remark || '';
photoPreview.value = record.photo || '';
modalVisible.value = true;
}
function resetForm() {
formData.equipmentName = '';
formData.equipType = undefined;
formData.customerId = undefined;
formData.building = '';
formData.floor = '';
formData.location = '';
formData.photo = '';
formData.remark = '';
photoPreview.value = '';
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData: any = {
equipmentName: formData.equipmentName,
equipType: formData.equipType,
customerId: formData.customerId,
building: formData.building,
floor: formData.floor,
location: formData.location,
photo: formData.photo,
remark: formData.remark,
};
let res: any;
if (isEdit.value && editId.value) {
res = await customerEquipmentApi.update(editId.value, submitData);
} else {
res = await customerEquipmentApi.create(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await customerEquipmentApi.delete(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped>
.device-equipment {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
padding: 20px 24px;
box-sizing: border-box;
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.equip-name {
font-weight: 600;
color: #0d1c2c;
}
.type-chip {
display: inline-block;
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 弹窗 */
.no-bar {
display: flex;
gap: 24px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 8px;
background: #f4f7ff;
border: 1px solid #dbe4ee;
}
.no-item {
display: flex;
align-items: baseline;
gap: 10px;
}
.no-label {
font-size: 12px;
color: #5b6b7e;
}
.no-value {
font-size: 14px;
font-weight: 700;
color: #0054cd;
}
.photo-field {
display: flex;
align-items: center;
gap: 12px;
}
.photo-preview {
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #dbe4ee;
background: #f8faff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.photo-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-empty {
font-size: 12px;
color: #a4b0bf;
}
.form-section {
margin-bottom: 4px;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f6;
}
.section-no {
width: 26px;
height: 26px;
border-radius: 7px;
background: #0054cd;
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Consolas, Menlo, monospace;
flex-shrink: 0;
}
.section-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.section-name {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.section-sub {
font-size: 11px;
color: #8a97a8;
letter-spacing: 0.5px;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>