新增上传和下载附件功能,选择器根据设备id联动

This commit is contained in:
Li1304553726 2025-05-27 10:27:56 +08:00
parent 38d93ddeab
commit fbc2c567e2
20 changed files with 1191 additions and 69 deletions

View File

@ -74,5 +74,25 @@ export class ResourceRouter {
const { staff } = ctx; const { staff } = ctx;
return await this.resourceService.findManyWithCursor(input); return await this.resourceService.findManyWithCursor(input);
}), }),
findByDeviceId: this.trpc.procedure
.input(
z.object({
deviceId: z.string(),
}),
)
.query(async ({ input }) => {
return this.resourceService.findByDeviceId(input.deviceId);
}),
linkToDevice: this.trpc.procedure
.input(
z.object({
fileId: z.string(),
deviceId: z.string(),
}),
)
.mutation(async ({ input }) => {
const { fileId, deviceId } = input;
return this.resourceService.linkToDevice(fileId, deviceId);
}),
}); });
} }

View File

@ -35,4 +35,22 @@ export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
}, },
}); });
} }
// 添加关联设备ID的方法
async linkToDevice(fileId: string, deviceId: string) {
return this.update({
where: { fileId },
data: { deviceId },
});
}
// 添加根据设备ID查询资源的方法
async findByDeviceId(deviceId: string) {
return this.findMany({
where: {
deviceId,
deletedAt: null,
},
});
}
} }

0
apps/web/public/LOGO.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

0
apps/web/src/app/main/devicepage/dashboard/page.tsx Normal file → Executable file
View File

View File

@ -1,13 +1,17 @@
// import { api, useStaff, useDevice } from "@nice/client"; // import { api, useStaff, useDevice } from "@nice/client";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Button, Form, Input, Modal, Select, Row, Col } from "antd"; import { Button, Form, Input, Modal, Select, Row, Col, Tabs, Card } from "antd";
import { useDevice } from "@nice/client"; import { useDevice } from "@nice/client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import DepartmentChildrenSelect from "@web/src/components/models/department/department-children-select"; import DepartmentChildrenSelect from "@web/src/components/models/department/department-children-select";
import DepartmentSelect from "@web/src/components/models/department/department-select"; import DepartmentSelect from "@web/src/components/models/department/department-select";
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select"; import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
import DeviceTypeSelect from "../select/Device-select"; import DeviceTypeSelect from "../select/Device-select";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import { api } from "@nice/client";
import type { TabsProps } from "antd";
export default function DeviceModal() { export default function DeviceModal() {
const { const {
form, form,
@ -18,28 +22,94 @@ export default function DeviceModal() {
setEditingRecord, setEditingRecord,
} = useMainContext(); } = useMainContext();
const { create, update } = useDevice(); const { create, update } = useDevice();
// 添加状态跟踪当前选择的网系类别和故障类型
const [selectedSystemType, setSelectedSystemType] = useState<string>();
const [selectedDeviceType, setSelectedDeviceType] = useState<string>();
// 在组件顶层声明mutation
const linkToDeviceMutation = api.resource.linkToDevice.useMutation();
// 初始化时从表单获取初始值
useEffect(() => {
if (visible) {
const systemType = form.getFieldValue("systemType");
const deviceType = form.getFieldValue("deviceType");
setSelectedSystemType(systemType);
setSelectedDeviceType(deviceType);
}
}, [visible, form]);
// 监听表单中系统类型的变化
useEffect(() => {
const systemType = form.getFieldValue("systemType");
if (systemType !== selectedSystemType) {
setSelectedSystemType(systemType);
}
}, [form.getFieldValue("systemType")]);
// 监听表单中故障类型的变化
useEffect(() => {
const deviceType = form.getFieldValue("deviceType");
if (deviceType !== selectedDeviceType) {
setSelectedDeviceType(deviceType);
}
}, [form.getFieldValue("deviceType")]);
const handleOk = async () => { const handleOk = async () => {
try { try {
const values = form.getFieldsValue(); const values = form.getFieldsValue();
const { attachments = [], ...deviceData } = values;
let deviceId;
if (editingRecord?.id) { if (editingRecord?.id) {
// 编辑现有记录
await update.mutateAsync({ await update.mutateAsync({
where: { id: editingRecord.id }, where: { id: editingRecord.id },
data: values data: deviceData,
}); });
toast.success("更新故障成功"); deviceId = editingRecord.id;
} else { } else {
// 创建新记录 const result = await create.mutateAsync(deviceData);
await create.mutateAsync(values); deviceId = result.id;
toast.success("创建故障成功");
} }
// 关闭模态框并重置 // 如果有附件,将它们关联到此设备
if (attachments.length > 0 && deviceId) {
try {
console.log("开始关联附件到设备:", deviceId, attachments);
// 使用组件顶层声明的mutation
for (const fileId of attachments) {
if (!fileId) continue;
try {
console.log(`正在关联文件 ${fileId} 到设备 ${deviceId}`);
// 使用已在顶层声明的mutation
await linkToDeviceMutation.mutateAsync({
fileId,
deviceId,
});
console.log(`文件 ${fileId} 关联成功`);
} catch (error) {
console.error(`文件 ${fileId} 关联失败:`, error);
}
}
} catch (err) {
console.error("关联附件失败:", err);
toast.error("附件关联失败,但设备信息已保存");
}
}
toast.success(editingRecord?.id ? "更新故障成功" : "创建故障成功");
setVisible(false); setVisible(false);
setEditingRecord(null); setEditingRecord(null);
form.resetFields(); form.resetFields();
} catch (error) { } catch (error) {
console.error("保存故障信息失败:", error); console.error("操作失败:", error);
toast.error("操作失败"); toast.error("操作失败");
} }
}; };
@ -51,12 +121,25 @@ export default function DeviceModal() {
form.resetFields(); form.resetFields();
}; };
// 处理网系类别变化
const handleSystemTypeChange = (value: string) => {
setSelectedSystemType(value);
// 如果网系类别变化,且故障类型不属于此网系类别,则清空故障类型
if (selectedDeviceType) {
form.setFieldValue("deviceType", undefined);
setSelectedDeviceType(undefined);
}
};
// 处理故障类型变化
const handleDeviceTypeChange = (value: string) => {
setSelectedDeviceType(value);
};
// 模态框标题根据是否编辑而变化 // 模态框标题根据是否编辑而变化
const modalTitle = editingRecord?.id ? "编辑故障信息" : "新增故障"; const modalTitle = editingRecord?.id ? "编辑故障信息" : "新增故障";
return ( return (
<> <>
<Modal <Modal
title={modalTitle} title={modalTitle}
@ -71,12 +154,20 @@ export default function DeviceModal() {
<Row gutter={16}> <Row gutter={16}>
<Col span={8}> <Col span={8}>
<Form.Item name="systemType" label="网系类别"> <Form.Item name="systemType" label="网系类别">
<SystemTypeSelect className="rounded-lg" /> <SystemTypeSelect
className="rounded-lg"
onChange={handleSystemTypeChange}
deviceTypeId={selectedDeviceType} // 传递当前选中的故障类型ID
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item name="deviceType" label="故障类型"> <Form.Item name="deviceType" label="故障类型">
<DeviceTypeSelect className="rounded-lg" /> <DeviceTypeSelect
className="rounded-lg"
systemTypeId={selectedSystemType}
onChange={handleDeviceTypeChange} // 添加故障类型变化的处理函数
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
@ -95,7 +186,6 @@ export default function DeviceModal() {
<DepartmentSelect /> <DepartmentSelect />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item name="deviceStatus" label="故障状态"> <Form.Item name="deviceStatus" label="故障状态">
<Select className="rounded-lg" placeholder="请选择故障状态"> <Select className="rounded-lg" placeholder="请选择故障状态">
@ -106,9 +196,39 @@ export default function DeviceModal() {
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="notes" label="描述"> {/* 使用Card和Tabs组件合并描述和附件上传 */}
<Input.TextArea rows={4} className="rounded-lg" /> <Card className="mt-2 rounded-lg">
<Tabs
defaultActiveKey="description"
items={[
{
key: "description",
label: "描述",
children: (
<Form.Item name="notes" noStyle>
<Input.TextArea
rows={4}
className="rounded-lg"
placeholder="请输入故障描述信息"
/>
</Form.Item> </Form.Item>
),
},
{
key: "attachments",
label: "附件",
children: (
<Form.Item name="attachments" noStyle>
<TusUploader
multiple={true}
description="点击或拖拽文件到此区域上传故障相关附件"
/>
</Form.Item>
),
},
]}
/>
</Card>
</Form> </Form>
</Modal> </Modal>
</> </>

View File

@ -0,0 +1,37 @@
import { Modal, Button } from "antd";
import React from "react";
interface DescriptionModalProps {
visible: boolean;
title: string;
description: string;
onClose: () => void;
}
const DescriptionModal: React.FC<DescriptionModalProps> = ({
visible,
title,
description,
onClose,
}) => {
return (
<Modal
title={title}
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
</Button>,
]}
>
<div className="py-4">
<div className="text-lg font-medium mb-2"></div>
<div className="bg-gray-50 p-4 rounded-md whitespace-pre-wrap">
{description}
</div>
</div>
</Modal>
);
};
export default DescriptionModal;

View File

@ -0,0 +1,45 @@
import { Button, Modal } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import toast from "react-hot-toast";
import React from "react";
interface DeviceActionsProps {
record: any;
onEdit: (record: any) => void;
onDelete: (id: string) => void;
}
const DeviceActions: React.FC<DeviceActionsProps> = ({
record,
onEdit,
onDelete
}) => {
const handleDelete = () => {
Modal.confirm({
title: "确认删除",
icon: <ExclamationCircleOutlined />,
content: `确定要删除故障 "${record.showname || "未命名故障"}" 吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk() {
onDelete(record.id);
},
});
};
return (
<div className="flex space-x-2 justify-center">
<Button
type="primary"
onClick={() => onEdit(record)}
>
</Button>
<Button danger onClick={handleDelete}>
</Button>
</div>
);
};
export default DeviceActions;

View File

@ -0,0 +1,113 @@
import { ColumnsType } from "antd/es/table";
import { formatDateTime } from "../utils/helpers";
import DeviceStatus from "./DeviceStatus";
import DeviceActions from "./DeviceActions";
interface CreateColumnsProps {
systemTypeTerms: any[];
deviceTypeTerms: any[];
onShowDesc: (record: any) => void;
onEdit: (record: any) => void;
onDelete: (id: string) => void;
}
export const createColumns = ({
systemTypeTerms,
deviceTypeTerms,
onShowDesc,
onEdit,
onDelete,
}: CreateColumnsProps): ColumnsType<any> => {
// 根据术语ID和类型获取术语名称
const getTermNameById = (termId: string, termType: string) => {
if (!termId) return "未知";
const terms = termType === "system_type" ? systemTypeTerms : deviceTypeTerms;
const term = terms?.find((t) => t.id === termId);
return term?.name || "未知";
};
return [
{
title: "网系类别",
dataIndex: "systemType",
key: "systemType",
align: "center",
render: (text, record) => {
return getTermNameById(record.systemType, "system_type");
},
},
{
title: "故障类型",
dataIndex: "deviceType",
key: "deviceType",
align: "center",
render: (text, record) => {
return getTermNameById(record.deviceType, "device_type");
},
},
{
title: "单位",
dataIndex: "deptId",
key: "deptId",
align: "center",
render: (text, record) => {
// 如果有部门关联,显示部门名称
if (record.deptId && (record as any)?.department?.name) {
return (record as any)?.department?.name;
}
// 否则显示responsiblePerson如果存在
if (record.responsiblePerson) {
return record.responsiblePerson;
}
// 最后才显示未知
return "未知";
},
},
{
title: "故障名称",
dataIndex: "showname",
key: "showname",
align: "center",
render: (text, record) => (
<div
onClick={() => onShowDesc(record)}
style={{
cursor: "pointer",
padding: "8px 0",
fontWeight: "bold",
}}
>
{text || "未命名故障"}
</div>
),
},
{
title: "故障状态",
dataIndex: "deviceStatus",
key: "deviceStatus",
align: "center",
render: (status) => <DeviceStatus status={status} />,
},
{
title: "时间",
dataIndex: "createdAt",
key: "createdAt",
align: "center",
render: (text, record) => formatDateTime(record.createdAt),
},
{
title: "操作",
key: "action",
align: "center",
render: (_, record) => (
<DeviceActions
record={record}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
};
export default createColumns;

View File

@ -0,0 +1,21 @@
import { Tag } from "antd";
import { getStatusConfig } from "../utils/helpers";
import React from "react";
interface DeviceStatusProps {
status: string;
}
const DeviceStatus: React.FC<DeviceStatusProps> = ({ status }) => {
const config = getStatusConfig(status);
return (
<Tag
color={config.color}
style={{ minWidth: "60px", textAlign: "center" }}
>
{config.text}
</Tag>
);
};
export default DeviceStatus;

View File

@ -0,0 +1,305 @@
// 新建文件: apps/web/src/components/resource/resource-file-list.tsx
import { useEffect, useState, useMemo } from "react";
import {
Empty,
List,
Typography,
Button,
Space,
Card,
Divider,
Tabs,
Image,
} from "antd";
import {
DownloadOutlined,
FileOutlined,
FileTextOutlined,
FilePdfOutlined,
FileImageOutlined,
EyeOutlined,
} from "@ant-design/icons";
import { api } from "@nice/client";
import { env } from "@web/src/env";
import { getCompressedImageUrl, formatFileSize } from "@nice/utils";
import { getFileIcon } from "@web/src/components/common/uploader/utils";
interface ResourceFileListProps {
deviceId: string;
}
interface ResourceItem {
id?: string;
title?: string;
fileId?: string;
createdAt?: Date | string;
url?: string;
type?: string;
meta?: any;
}
export default function ResourceFileList({ deviceId }: ResourceFileListProps) {
// 直接在组件顶层使用useQuery
const { data, isLoading, refetch } = api.resource.findMany.useQuery(
{
where: {
deviceId: deviceId,
},
},
{
refetchOnWindowFocus: true,
staleTime: 3000,
enabled: !!deviceId, // 只有当deviceId存在时才执行查询
}
);
useEffect(() => {
// 每3秒刷新一次数据
const intervalId = setInterval(() => {
if (deviceId) {
refetch();
}
}, 3000);
return () => clearInterval(intervalId);
}, [deviceId, refetch]);
// 使用useMemo处理资源数据
const { resources, imageResources, fileResources } = useMemo(() => {
if (!data || !Array.isArray(data))
return { resources: [], imageResources: [], fileResources: [] };
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
// 使用显式类型断言,避免递归类型推断
const processedResources = data.map((resource: any) => {
const fileUrl = resource.url || resource.fileId;
if (!fileUrl) return resource;
const originalUrl = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${fileUrl}`;
const isImageFile = isImage(fileUrl);
// console.log("环境变量:", {
// SERVER_IP: env.SERVER_IP,
// FILE_PORT: env.FILE_PORT,
// fileUrl: fileUrl,
// });
return {
...resource,
originalUrl,
isImage: isImageFile,
} as ResourceItem & { originalUrl: string; isImage: boolean };
});
return {
resources: processedResources,
imageResources: processedResources.filter((item) => item.isImage),
fileResources: processedResources.filter((item) => !item.isImage),
};
}, [data]);
// 获取文件URL保留你的tabs结构但改进URL生成逻辑
const getFileUrl = (fileId: string | undefined) => {
if (!fileId) return "";
// 使用与ResourceShower相同的URL格式
return `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${fileId}`;
};
const getCompressedUrl = (fileId: string | undefined) => {
if (!fileId) return "";
return getCompressedImageUrl(getFileUrl(fileId));
};
if (isLoading) {
return (
<div className="text-center py-4 bg-gray-50 rounded-md">
...
</div>
);
}
if (resources.length === 0) {
return (
<Empty description="暂无附件" className="bg-gray-50 p-4 rounded-md" />
);
}
return (
<div className="bg-gray-50 rounded-md p-3">
<Tabs
defaultActiveKey="all"
items={[
{
key: "all",
label: "全部附件",
children: (
<List
grid={{ gutter: 16, column: 2 }}
dataSource={resources}
renderItem={(item: any) => (
<List.Item>
<Card
size="small"
hoverable
className="flex flex-col h-full"
actions={[
...(item.isImage
? [
<Button
key="preview"
type="link"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
window.open(
item.originalUrl || getFileUrl(item.fileId),
"_blank"
);
}}
>
</Button>,
]
: []),
<Button
key="download"
type="link"
icon={<DownloadOutlined />}
onClick={() => {
const url =
"originalUrl" in item
? (item as any).originalUrl
: getFileUrl(item.fileId);
if (url) window.open(url, "_blank");
}}
>
</Button>,
]}
>
<div className="flex items-center mb-2">
{item.isImage ? (
<FileImageOutlined
style={{ fontSize: 24, color: "#1890ff" }}
/>
) : (
getFileIcon(item.url || "")
)}
<Typography.Text strong className="ml-2 truncate">
{item.title || `文件-${item.id}`}
</Typography.Text>
</div>
<Divider className="my-2" />
<Typography.Text type="secondary" className="text-xs">
:{" "}
{item.meta?.size
? formatFileSize(item.meta.size)
: "未知"}
</Typography.Text>
<br />
<Typography.Text type="secondary" className="text-xs">
:{" "}
{item.createdAt
? new Date(item.createdAt).toLocaleString()
: "未知"}
</Typography.Text>
</Card>
</List.Item>
)}
/>
),
},
{
key: "images",
label: "图片附件",
children:
imageResources.length > 0 ? (
<div className="p-2">
<Image.PreviewGroup>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{imageResources.map((item) => (
<div
key={item.fileId || item.id}
className="aspect-square rounded-lg overflow-hidden shadow-sm border border-gray-100 relative group flex items-center justify-center bg-gray-100"
>
<Image
src={
item.originalUrl || getCompressedUrl(item.fileId)
}
alt={item.title || "图片"}
preview={{
src: item.originalUrl || getFileUrl(item.fileId),
mask: (
<div className="flex items-center justify-center text-white">
</div>
),
}}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
objectPosition: "center",
}}
className="rounded-lg"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-1 text-xs opacity-0 group-hover:opacity-100 transition-opacity">
{item.title || `图片-${item.id}`}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
</div>
) : (
<Empty description="暂无图片附件" />
),
},
{
key: "files",
label: "文档附件",
children:
fileResources.length > 0 ? (
<div className="rounded-xl p-1 border border-gray-100 bg-white">
<div className="flex flex-wrap gap-2">
{fileResources.map((item) => (
<a
key={item.fileId || item.id}
className="flex-shrink-0 relative active:scale-95 transition-transform select-none"
href={item.originalUrl || getFileUrl(item.fileId)}
target="_blank"
download={true}
title={`点击下载"${item.title || "未命名文件"}"`}
>
<div className="w-[120px] h-[90px] p-2 flex flex-col items-center justify-between rounded-xl hover:bg-blue-50 cursor-pointer">
<div className="text-blue-600 text-xl">
{getFileIcon(item.url || "")}
</div>
<div className="w-full text-center space-y-0.5">
<p className="text-xs font-medium text-gray-800 truncate">
{item.title?.slice(0, 12) || "未命名"}
</p>
<div className="flex items-center justify-between text-xs text-gray-500">
<span className="bg-gray-100 px-1 rounded-sm">
{item.url?.split(".").pop()?.toUpperCase() ||
"文件"}
</span>
<span className="bg-gray-100 px-1 rounded-sm">
{item.meta?.size
? formatFileSize(item.meta.size)
: ""}
</span>
</div>
</div>
</div>
</a>
))}
</div>
</div>
) : (
<Empty description="暂无文档附件" />
),
},
]}
/>
</div>
);
}

View File

@ -0,0 +1,73 @@
import { Button } from "antd";
import { ImportOutlined, ExportOutlined } from "@ant-design/icons";
import { useRef } from "react";
import React from "react";
interface TableHeaderProps {
selectedCount: number;
onExportTemplate: () => void;
onImport: (file: File) => void;
onExport: () => void;
}
const TableHeader: React.FC<TableHeaderProps> = ({
selectedCount,
onExportTemplate,
onImport,
onExport,
}) => {
const uploadRef = useRef<HTMLInputElement>(null);
const handleImportClick = () => {
if (uploadRef.current) {
uploadRef.current.click();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
onImport(files[0]);
// 清空文件输入,允许再次选择同一文件
if (uploadRef.current) uploadRef.current.value = "";
};
return (
<div className="w-full flex justify-between mb-2">
<span></span>
<div className="flex space-x-2">
<input
type="file"
ref={uploadRef}
onChange={handleFileChange}
style={{ display: "none" }}
accept=".xlsx,.xls,.csv"
/>
<Button
icon={<ImportOutlined />}
type="primary"
onClick={onExportTemplate}
>
</Button>
<Button
icon={<ImportOutlined />}
type="primary"
onClick={handleImportClick}
>
</Button>
<Button
type="primary"
onClick={onExport}
disabled={selectedCount === 0}
icon={<ExportOutlined />}
>
{selectedCount > 0 ? `导出 (${selectedCount})项数据` : "导出选中数据"}
</Button>
</div>
</div>
);
};
export default TableHeader;

View File

@ -18,6 +18,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { utils, writeFile, read } from "xlsx"; import { utils, writeFile, read } from "xlsx";
import ResourceFileList from "./components/ResourceFileList";
// 提取处理嵌套字段的函数 // 提取处理嵌套字段的函数
const getNestedValue = (record: any, dataIndex: string | string[]) => { const getNestedValue = (record: any, dataIndex: string | string[]) => {
@ -49,11 +50,16 @@ const DeviceTable = forwardRef(
const { create } = useDevice(); const { create } = useDevice();
// 描述信息模态框状态:控制可见性和内容 // 描述信息模态框状态:控制可见性和内容
const [descModalVisible, setDescModalVisible] = useState(false); const [descModalVisible, setDescModalVisible] = useState(false);
const [currentDesc, setCurrentDesc] = useState({ title: "", desc: "" }); const [currentDesc, setCurrentDesc] = useState<{
id: string;
title: string;
desc: string;
}>({ id: "", title: "", desc: "" });
// 处理点击故障名称显示描述信息 // 处理点击故障名称显示描述信息
const handleShowDesc = (record) => { const handleShowDesc = (record) => {
setCurrentDesc({ setCurrentDesc({
id: record.id,
title: record.showname || "未知故障", title: record.showname || "未知故障",
desc: record.notes || "无故障详情", desc: record.notes || "无故障详情",
}); });
@ -656,6 +662,14 @@ const DeviceTable = forwardRef(
<div className="bg-gray-50 p-4 rounded-md whitespace-pre-wrap"> <div className="bg-gray-50 p-4 rounded-md whitespace-pre-wrap">
{currentDesc.desc} {currentDesc.desc}
</div> </div>
{/* 添加附件展示区域 */}
{currentDesc?.id && (
<div className="mt-4">
<div className="text-lg font-medium mb-2"></div>
<ResourceFileList deviceId={currentDesc.id} />
</div>
)}
</div> </div>
</Modal> </Modal>
</> </>

View File

@ -0,0 +1,193 @@
import { utils, writeFile, read } from "xlsx";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import { findTermIdByName, findDeptIdByName, getStatusKeyByValue } from "../utils/helpers";
export class ImportExportService {
// 导出选中数据
static exportSelectedData(
selectedData: any[],
getTermNameById: (id: string, type: string) => string
) {
if (!selectedData || selectedData.length === 0) {
toast.error("没有选中任何数据");
return;
}
try {
// 格式化导出数据
const exportData = selectedData.map((item) => ({
网系类别: getTermNameById(item.systemType, "system_type"),
故障类型: getTermNameById(item.deviceType, "device_type"),
: (item as any)?.department?.name || "未知",
故障名称: item?.showname || "未命名故障",
: (() => {
const statusMap = {
normal: "已修复",
maintenance: "维修中",
broken: "未修复",
idle: "闲置",
};
return statusMap[item.deviceStatus] || "未知";
})(),
时间: item.createdAt
? dayjs(item.createdAt).format("YYYY-MM-DD")
: "未知",
描述: item.notes || "无描述",
}));
// 创建工作簿
const wb = utils.book_new();
const ws = utils.json_to_sheet(exportData);
utils.book_append_sheet(wb, ws, "故障数据");
// 导出Excel文件
writeFile(wb, `故障数据_${dayjs().format("YYYYMMDD_HHmmss")}.xlsx`);
toast.success(`成功导出 ${selectedData.length} 条数据`);
} catch (error) {
console.error("导出数据错误:", error);
toast.error("导出失败");
}
}
// 导出模板
static exportTemplate(systemTypeTerms: any[], deviceTypeTerms: any[]) {
try {
// 创建一个示例记录
const templateData = [
{
网系类别: systemTypeTerms?.[0]?.name || "网系类别1",
故障类型: deviceTypeTerms?.[0]?.name || "故障类型1",
: "单位名称",
: "示例故障名称",
: "未修复", // 可选值:已修复, 维修中, 未修复
: "这是一个示例描述",
},
];
// 创建工作簿
const wb = utils.book_new();
const ws = utils.json_to_sheet(templateData);
utils.book_append_sheet(wb, ws, "导入模板");
// 导出Excel文件
writeFile(wb, `故障导入模板.xlsx`);
toast.success("已下载导入模板");
} catch (error) {
console.error("导出模板失败:", error);
toast.error("下载模板失败");
}
}
// 导入数据
static async importData(
file: File,
systemTypeTerms: any[],
deviceTypeTerms: any[],
departments: any[],
devices: any[],
createRecord: (data: any) => Promise<any>
) {
try {
// 读取Excel/CSV文件
const data = await file.arrayBuffer();
const workbook = read(data);
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = utils.sheet_to_json(worksheet);
// 转换为符合系统格式的数据
const records = jsonData.map((row: any) => {
const unitName = row["单位"] ? String(row["单位"]) : "";
return {
systemType: findTermIdByName(row["网系类别"], systemTypeTerms),
deviceType: findTermIdByName(row["故障类型"], deviceTypeTerms),
showname: row["故障名称"] ? String(row["故障名称"]) : "",
// 尝试关联部门ID
deptId: unitName ? findDeptIdByName(unitName, departments, devices) : null,
// 同时保存原始单位名称
responsiblePerson: unitName,
deviceStatus: getStatusKeyByValue(row["故障状态"]),
notes: row["描述"] ? String(row["描述"]) : "",
};
});
// 确认是否有有效数据
if (records.length === 0) {
toast.error("未找到有效数据");
return false;
}
// 批量创建记录
await this.batchImportRecords(records, createRecord);
return true;
} catch (error) {
console.error("导入失败:", error);
toast.error("导入失败,请检查文件格式");
return false;
}
}
// 批量导入记录
static async batchImportRecords(
records: any[],
createRecord: (data: any) => Promise<any>
) {
if (!records || records.length === 0) return;
try {
// 过滤出有效记录
const validRecords = records.filter(
(record) => record.systemType || record.deviceType || record.showname
);
if (validRecords.length === 0) {
toast.error("没有找到有效的记录数据");
return;
}
// 设置批处理大小
const batchSize = 5;
let successCount = 0;
let totalProcessed = 0;
// 显示进度提示
const loadingToast = toast.loading(`正在导入数据...`);
// 分批处理数据
for (let i = 0; i < validRecords.length; i += batchSize) {
const batch = validRecords.slice(i, i + batchSize);
// 串行处理每一批数据
for (const record of batch) {
try {
await createRecord(record);
successCount++;
} catch (error) {
console.error(
`导入记录失败: ${record.showname || "未命名"}`,
error
);
}
}
totalProcessed += batch.length;
// 更新导入进度
toast.loading(
`已处理 ${totalProcessed}/${validRecords.length} 条数据...`,
{ id: loadingToast }
);
}
toast.dismiss(loadingToast);
// 显示结果
if (successCount === validRecords.length) {
toast.success(`成功导入 ${successCount} 条数据`);
} else {
toast.success(
`成功导入 ${successCount}/${validRecords.length} 条数据,部分记录导入失败`
);
}
} catch (error) {
console.error("批量导入失败:", error);
toast.error("导入过程中发生错误");
}
}
}
export default ImportExportService;

View File

@ -0,0 +1,79 @@
import dayjs from "dayjs";
// 提取处理嵌套字段的函数
export const getNestedValue = (record: any, dataIndex: string | string[]) => {
if (Array.isArray(dataIndex)) {
return dataIndex.reduce((obj, key) => obj?.[key], record);
}
return record[dataIndex];
};
// 根据术语ID和类型获取术语名称
export const getTermNameById = (termId: string, terms: any[], fallback = "未知") => {
if (!termId) return fallback;
const term = terms?.find((t) => t.id === termId);
return term?.name || fallback;
};
// 获取状态键值对应
export const getStatusKeyByValue = (value: string) => {
const statusMap: Record<string, string> = {
: "normal",
: "maintenance",
"未修复": "broken",
};
return statusMap[value] || "normal";
};
// 根据状态获取显示配置
export const getStatusConfig = (status: string) => {
const statusConfig = {
normal: {
text: "已修复",
color: "success",
},
maintenance: {
text: "维修中",
color: "processing",
},
broken: {
text: "未修复",
color: "error",
},
};
return statusConfig[status] || {
text: "未知",
color: "default",
};
};
// 格式化日期时间
export const formatDateTime = (dateTime: string | Date | null) => {
return dateTime ? dayjs(dateTime).format("YYYY-MM-DD HH:mm:ss") : "未知";
};
// 查找术语ID
export const findTermIdByName = (
name: string,
terms: any[]
) => {
if (!name) return null;
const term = terms?.find((t) => t.name === name);
return term?.id || null;
};
// 查找部门ID
export const findDeptIdByName = (name: string, departments: any[], devices: any[]) => {
if (!name || name === "未知") return null;
// 直接从部门数据中查找
const department = departments?.find((dept) => dept.name === name);
if (department) {
return department.id;
}
// 备用:尝试从设备中查找
const matchedDevice = devices?.find(
(device) => (device as any)?.department?.name === name
);
return matchedDevice?.deptId || null;
};

View File

@ -152,7 +152,6 @@ export default function DeviceMessage() {
</Button> </Button>
</div> </div>
<div className="flex flex-wrap items-center gap-2 mb-4"> <div className="flex flex-wrap items-center gap-2 mb-4">
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<SystemTypeSelect <SystemTypeSelect
@ -204,13 +203,6 @@ export default function DeviceMessage() {
</Button> </Button>
</div> </div>
{/* <div className="flex justify-end gap-2 mb-4 border-b pb-2">
<Button icon={<ExportOutlined />}></Button>
<Button icon={<ImportOutlined />}></Button>
<Button icon={<ExportOutlined />}></Button>
</div> */}
<div> <div>
<DeviceTable ref={tableRef} onSelectedChange={handleSelectedChange} /> <DeviceTable ref={tableRef} onSelectedChange={handleSelectedChange} />
<DeviceModal /> <DeviceModal />

0
apps/web/src/app/main/devicepage/select/Fix-select.tsx Normal file → Executable file
View File

View File

@ -1,7 +1,7 @@
// apps/web/src/components/models/term/system-type-select.tsx // apps/web/src/components/models/term/system-type-select.tsx
import { Select } from "antd"; import { Select } from "antd";
import { api } from "@nice/client"; import { api } from "@nice/client";
import React from "react"; import React, { useEffect, useState } from "react";
interface SystemTypeSelectProps { interface SystemTypeSelectProps {
value?: string; value?: string;
@ -10,6 +10,7 @@ interface SystemTypeSelectProps {
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
deviceTypeId?: string; // 添加故障类型ID参数用于筛选
} }
export default function SystemTypeSelect({ export default function SystemTypeSelect({
@ -19,8 +20,16 @@ export default function SystemTypeSelect({
disabled = false, disabled = false,
className, className,
style, style,
deviceTypeId, // 接收故障类型ID
}: SystemTypeSelectProps) { }: SystemTypeSelectProps) {
const { data: terms, isLoading } = api.term.findMany.useQuery({ const [options, setOptions] = useState<{ label: string; value: string }[]>(
[]
);
const [loading, setLoading] = useState(false);
// 获取所有网系类别
const { data: allSystemTypes, isLoading: isSystemLoading } =
api.term.findMany.useQuery({
where: { where: {
taxonomy: { slug: "system_type" }, taxonomy: { slug: "system_type" },
deletedAt: null, deletedAt: null,
@ -29,20 +38,98 @@ export default function SystemTypeSelect({
orderBy: { order: "asc" }, orderBy: { order: "asc" },
}); });
const options = // 获取所有故障类型数据(包括父级信息)
terms?.map((term) => ({ const { data: allDeviceTypes, isLoading: isDeviceTypesLoading } =
api.term.findMany.useQuery({
where: {
taxonomy: { slug: "device_type" },
deletedAt: null,
},
include: {
parent: {
select: {
id: true,
name: true,
},
},
},
});
// 处理网系类别列表
useEffect(() => {
setLoading(isSystemLoading || isDeviceTypesLoading);
if (allSystemTypes) {
// 如果指定了故障类型
if (deviceTypeId && allDeviceTypes) {
// 查找故障类型对应的信息
const deviceType = allDeviceTypes.find((dt) => dt.id === deviceTypeId);
if (deviceType && deviceType.parentId) {
// 找到对应的网系类别
const parentSystemType = allSystemTypes.find(
(st) => st.id === deviceType.parentId
);
if (parentSystemType) {
// 只显示该故障类型对应的网系类别
setOptions([
{
label: parentSystemType.name,
value: parentSystemType.id,
},
]);
// 如果当前值与父级不匹配,则自动更新表单值
if (value !== parentSystemType.id) {
onChange?.(parentSystemType.id);
}
} else {
// 如果找不到对应的网系类别,则显示所有
setOptions(
allSystemTypes.map((term) => ({
label: term.name, label: term.name,
value: term.id, value: term.id,
})) || []; }))
);
}
} else {
// 如果故障类型没有父级,显示所有网系类别
setOptions(
allSystemTypes.map((term) => ({
label: term.name,
value: term.id,
}))
);
}
} else {
// 没有指定故障类型,显示所有网系类别
setOptions(
allSystemTypes.map((term) => ({
label: term.name,
value: term.id,
}))
);
}
}
}, [
allSystemTypes,
deviceTypeId,
allDeviceTypes,
isSystemLoading,
isDeviceTypesLoading,
value,
onChange,
]);
return ( return (
<Select <Select
loading={isLoading} loading={loading}
value={value} value={value}
onChange={onChange} onChange={onChange}
options={options} options={options}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled || (deviceTypeId && options.length === 1)} // 如果只有一个选项且有故障类型ID则禁用选择
className={className} className={className}
style={style} style={style}
showSearch showSearch

View File

@ -2,6 +2,7 @@ import { HTMLMotionProps, motion } from 'framer-motion';
import { forwardRef, ReactNode } from 'react'; import { forwardRef, ReactNode } from 'react';
import { cn } from '@web/src/utils/classname'; import { cn } from '@web/src/utils/classname';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import React from 'react';
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> { export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' | variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' |

View File

@ -4,6 +4,7 @@ export const env: {
VERSION: string; VERSION: string;
FILE_PORT: string; FILE_PORT: string;
SERVER_PORT: string; SERVER_PORT: string;
UPLOAD_PORT: string;
} = { } = {
APP_NAME: import.meta.env.PROD APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME ? (window as any).env.VITE_APP_APP_NAME
@ -20,4 +21,7 @@ export const env: {
VERSION: import.meta.env.PROD VERSION: import.meta.env.PROD
? (window as any).env.VITE_APP_VERSION ? (window as any).env.VITE_APP_VERSION
: import.meta.env.VITE_APP_VERSION, : import.meta.env.VITE_APP_VERSION,
UPLOAD_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_UPLOAD_PORT
: import.meta.env.VITE_APP_UPLOAD_PORT,
}; };

View File

@ -178,8 +178,6 @@ model AppConfig {
@@map("app_config") @@map("app_config")
} }
model Resource { model Resource {
id String @id @default(cuid()) @map("id") id String @id @default(cuid()) @map("id")
title String? @map("title") title String? @map("title")
@ -199,6 +197,8 @@ model Resource {
isPublic Boolean? @default(true) @map("is_public") isPublic Boolean? @default(true) @map("is_public")
owner Staff? @relation(fields: [ownerId], references: [id]) owner Staff? @relation(fields: [ownerId], references: [id])
ownerId String? @map("owner_id") ownerId String? @map("owner_id")
deviceId String? @map("device_id")
device Device? @relation("DeviceResources", fields: [deviceId], references: [id])
// 索引 // 索引
@@index([type]) @@index([type])
@ -226,6 +226,7 @@ model Device {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
resources Resource[] @relation("DeviceResources")
@@index([deptId]) @@index([deptId])
@@index([systemType]) @@index([systemType])
@ -233,4 +234,3 @@ model Device {
@@index([responsiblePerson]) @@index([responsiblePerson])
@@map("device") @@map("device")
} }