Bläddra i källkod

新增人脸上传和列表渲染

pm 3 månader sedan
förälder
incheckning
13b7630174
6 ändrade filer med 244 tillägg och 25 borttagningar
  1. 2 2
      .env
  2. 10 0
      src/api/user/characteristic.ts
  3. 205 21
      src/components/user/FaceOrFingerForm.tsx
  4. 7 1
      src/locales/en.json
  5. 7 1
      src/locales/zh.json
  6. 13 0
      src/types/index.ts

+ 2 - 2
.env

@@ -4,8 +4,8 @@ VITE_APP_TITLE=能量隔离系统
 # 项目本地运行端口号
 VITE_PORT=81
 # 请求路径
-VITE_BASE_URL='http://192.168.0.10:48080'
-# VITE_BASE_URL='http://120.27.232.27:9292'
+# VITE_BASE_URL='http://192.168.0.10:48080'
+VITE_BASE_URL='http://120.27.232.27:9292'
 # open 运行 npm run dev 时自动打开浏览器
 VITE_OPEN=true
 

+ 10 - 0
src/api/user/characteristic.ts

@@ -19,5 +19,15 @@ export const userCharacteristicApi = {
   deleteUserFaceOrFinger: (ids: number): Promise<void> => {
     return axiosInstance.delete(`/iscs/user-characteristic/deleteUserCharacteristicList?ids=${ids}`);
   },
+
+  // 新增用户人脸(上传图片,仅传 userName、file;userName 的值为列表的 username)
+  insertUserFace: (userName: string, file: File): Promise<void> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('userName', userName);
+    return axiosInstance.post('/iscs/user-characteristic/insertUserFace', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    });
+  },
 };
 

+ 205 - 21
src/components/user/FaceOrFingerForm.tsx

@@ -1,11 +1,96 @@
-import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
-import { Modal, Table, Button, Image, message, Space } from 'antd';
-import { DeleteOutlined, ZoomInOutlined } from '@ant-design/icons';
+import React, { useState, useEffect, useImperativeHandle, forwardRef, useRef } from 'react';
+import { Modal, Table, Button, Image, message, Space, Upload, Input } from 'antd';
+import { DeleteOutlined, ZoomInOutlined, PlusOutlined } from '@ant-design/icons';
 import { userCharacteristicApi } from '../../api/user/characteristic';
 import { UserCharacteristic } from '../../types';
 import { formatDateTimeFull } from '../../utils/formatTime';
 import type { ColumnsType } from 'antd/es/table';
 import { useTranslation } from 'react-i18next';
+import axiosInstance from '../../utils/axios';
+
+/** 带鉴权加载的图片:通过 axios 携带 token 请求,解决 img 直链 401 不显示 */
+const AuthImage = ({
+  src,
+  alt,
+  width = 60,
+  height = 60,
+  style,
+  noImageText,
+}: {
+  src: string;
+  alt: string;
+  width?: number;
+  height?: number;
+  style?: React.CSSProperties;
+  noImageText?: string;
+}) => {
+  const [blobUrl, setBlobUrl] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const blobUrlRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    if (!src) {
+      setLoading(false);
+      setError(true);
+      return;
+    }
+    setLoading(true);
+    setError(false);
+    let cancelled = false;
+    axiosInstance
+      .get(src, { responseType: 'blob' })
+      .then((blob) => {
+        if (cancelled) return;
+        const url = URL.createObjectURL(blob as Blob);
+        blobUrlRef.current = url;
+        setBlobUrl(url);
+      })
+      .catch(() => {
+        if (!cancelled) setError(true);
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false);
+      });
+    return () => {
+      cancelled = true;
+      if (blobUrlRef.current) {
+        URL.revokeObjectURL(blobUrlRef.current);
+        blobUrlRef.current = null;
+      }
+      setBlobUrl(null);
+    };
+  }, [src]);
+
+  const placeholderStyle: React.CSSProperties = {
+    width,
+    height,
+    borderRadius: 4,
+    border: '1px solid #dcdfe6',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    color: '#999',
+    fontSize: 12,
+  };
+
+  if (loading) {
+    return <div style={placeholderStyle}>...</div>;
+  }
+  if (error || !blobUrl) {
+    return <div style={placeholderStyle}>{noImageText || '暂无图片'}</div>;
+  }
+  return (
+    <Image
+      width={width}
+      height={height}
+      src={blobUrl}
+      alt={alt}
+      preview={{ mask: <ZoomInOutlined /> }}
+      style={{ borderRadius: 4, border: '1px solid #dcdfe6', ...style }}
+    />
+  );
+};
 
 interface FaceOrFingerFormProps {
   onSuccess?: () => void;
@@ -30,6 +115,13 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
   const [userId, setUserId] = useState<number>();
   const [userType, setUserType] = useState<string>();
   const [dataType, setDataType] = useState<'finger' | 'face'>('finger');
+  const [currentUserName, setCurrentUserName] = useState('');
+
+  // 新增人脸:上传弹框
+  const [uploadVisible, setUploadVisible] = useState(false);
+  const [uploadFileList, setUploadFileList] = useState<any[]>([]);
+  const [uploadUserName, setUploadUserName] = useState('');
+  const [uploadLoading, setUploadLoading] = useState(false);
 
   // 打开弹窗
   const open = async (type: string, id: number, row: any) => {
@@ -39,6 +131,7 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
     setDialogTitle(isFinger ? t('form.fingerprintData') : t('form.faceData'));
     setUserId(row.id);
     setUserType(isFinger ? '1' : '2');
+    setCurrentUserName(row?.username ?? row?.nickname ?? '');
     setQueryParams({ pageNo: 1, pageSize: 10 });
     setTotal(0);
     setSelectedRowKeys([]);
@@ -49,7 +142,7 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
     open,
   }));
 
-  // 获取指纹或人脸列表
+  // 获取指纹或人脸列表(兼容接口返回 { code, data: { list, total }, msg },axios 会解包为 { list, total })
   const getFaceOrFingerList = async () => {
     if (!userId || !userType) return;
 
@@ -61,8 +154,10 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
         userId: userId,
         type: userType,
       });
-      setTableData(response.list || []);
-      setTotal(response.total || 0);
+      const list = response?.data?.list ?? response?.list ?? [];
+      const totalCount = response?.data?.total ?? response?.total ?? 0;
+      setTableData(Array.isArray(list) ? list : []);
+      setTotal(typeof totalCount === 'number' ? totalCount : 0);
     } catch (error: any) {
       message.error(error.message || t('form.getDataFailed'));
     } finally {
@@ -76,6 +171,37 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
     }
   }, [queryParams.pageNo, queryParams.pageSize, dialogVisible]);
 
+  const openUploadModal = () => {
+    setUploadFileList([]);
+    setUploadUserName(currentUserName);
+    setUploadVisible(true);
+  };
+
+  const handleUploadSubmit = async () => {
+    const userName = uploadUserName?.trim();
+    if (!userName) {
+      message.warning(t('form.userName') + (t('common.required') ? ` ${t('common.required')}` : '不能为空'));
+      return;
+    }
+    const file = uploadFileList[0]?.originFileObj;
+    if (!file) {
+      message.warning(t('form.pleaseSelectImage'));
+      return;
+    }
+    setUploadLoading(true);
+    try {
+      await userCharacteristicApi.insertUserFace(userName, file);
+      message.success(t('common.uploadSuccess') || '上传成功');
+      setUploadVisible(false);
+      setUploadFileList([]);
+      await getFaceOrFingerList();
+    } catch (error: any) {
+      message.error(error?.message || t('common.uploadFailed') || '上传失败');
+    } finally {
+      setUploadLoading(false);
+    }
+  };
+
   // 删除指纹或人脸
   const handleDelete = async (id?: number) => {
     const idsToDelete = id ? [id] : selectedRowKeys.map(key => Number(key));
@@ -120,20 +246,26 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
     {
       title: dataType === 'finger' ? t('form.fingerprint') : t('form.face'),
       align: 'center',
-      render: (_: any, record: UserCharacteristic) => (
-        <div style={{ position: 'relative', display: 'inline-block' }}>
-          <Image
-            width={60}
-            height={60}
-            src={record.imageUrl}
-            alt={dataType === 'finger' ? t('form.fingerprint') : t('form.face')}
-            preview={{
-              mask: <ZoomInOutlined />,
-            }}
-            style={{ borderRadius: 4, border: '1px solid #dcdfe6' }}
-          />
-        </div>
-      ),
+      render: (_: any, record: UserCharacteristic) => {
+        const imgSrc = record.imageUrl || record.imagePath;
+        return (
+          <div style={{ position: 'relative', display: 'inline-block' }}>
+            {imgSrc ? (
+              <AuthImage
+                src={imgSrc}
+                alt={dataType === 'finger' ? t('form.fingerprint') : t('form.face')}
+                width={60}
+                height={60}
+                noImageText={t('form.noImage') || '暂无图片'}
+              />
+            ) : (
+              <div style={{ width: 60, height: 60, borderRadius: 4, border: '1px solid #dcdfe6', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', fontSize: 12 }}>
+                {t('form.noImage') || '暂无图片'}
+              </div>
+            )}
+          </div>
+        );
+      },
     },
     {
       title: t('table.createTime'),
@@ -179,7 +311,17 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
       width={800}
       destroyOnClose
     >
-      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
+        {dataType === 'face' && (
+          <Button
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={openUploadModal}
+            disabled={formLoading}
+          >
+            {t('common.add')}
+          </Button>
+        )}
         <Button
           type="primary"
           danger
@@ -202,6 +344,48 @@ const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(
         bordered
       />
 
+      {/* 新增人脸:图片上传弹框(含 userName 输入框,中英文用 form.uploadFace / form.userName / form.selectImage / form.pleaseSelectImage) */}
+      <Modal
+        title={t('form.uploadFace')}
+        open={uploadVisible}
+        onCancel={() => { setUploadVisible(false); setUploadFileList([]); }}
+        onOk={handleUploadSubmit}
+        confirmLoading={uploadLoading}
+        okText={t('common.confirm')}
+        cancelText={t('common.cancel')}
+        destroyOnClose
+      >
+        <div style={{ marginBottom: 16 }}>
+          <label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
+            {t('form.userName')}
+          </label>
+          <Input
+            value={uploadUserName}
+            onChange={(e) => setUploadUserName(e.target.value)}
+            placeholder={t('form.userName')}
+            allowClear
+          />
+        </div>
+        <div>
+          <label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
+            {t('form.selectImage')}
+          </label>
+          <Upload
+            accept="image/*"
+            fileList={uploadFileList}
+            listType="picture-card"
+            maxCount={1}
+            beforeUpload={(file) => {
+              setUploadFileList([{ uid: file.uid, name: file.name, status: 'done', originFileObj: file }]);
+              return false;
+            }}
+            onRemove={() => setUploadFileList([])}
+          >
+            {uploadFileList.length === 0 && t('form.selectImage')}
+          </Upload>
+        </div>
+      </Modal>
+
       {/* 分页 */}
       {total > 0 && (
         <div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>

+ 7 - 1
src/locales/en.json

@@ -266,6 +266,8 @@
     "endDate": "End Date",
     "uploadFile": "Upload File",
     "upload": "Upload",
+    "uploadSuccess": "Upload successful",
+    "uploadFailed": "Upload failed",
     "cardContainer": "Card Container",
     "featureInDevelopment": "Feature in Development",
     "refresh": "Refresh",
@@ -850,6 +852,7 @@
     "confirmDeleteSelectedData": "Are you sure you want to delete {{count}} selected data?",
     "fingerprint": "Fingerprint",
     "face": "Face",
+    "noImage": "No Image",
     "getDataFailed": "Failed to get data",
     "close": "Close",
     "resetPasswordTitle": "Reset Password",
@@ -857,7 +860,10 @@
     "resetPasswordNewPassword": ":",
     "resetPasswordPlaceholder": "Please enter new password",
     "assignRoleTitle": "Assign Role",
-    "userName": "User Name"
+    "userName": "User Name",
+    "uploadFace": "Upload Face Photo",
+    "pleaseSelectImage": "Please select an image to upload",
+    "selectImage": "Select Image"
   },
   "role": {
     "addRole": "Add Role",

+ 7 - 1
src/locales/zh.json

@@ -266,6 +266,8 @@
     "endDate": "结束日期",
     "uploadFile": "上传文件",
     "upload": "上传",
+    "uploadSuccess": "上传成功",
+    "uploadFailed": "上传失败",
     "cardContainer": "卡片容器",
     "featureInDevelopment": "功能开发中",
     "refresh": "刷新",
@@ -852,6 +854,7 @@
     "confirmDeleteSelectedData": "确定要删除选中的 {{count}} 条数据吗?",
     "fingerprint": "指纹",
     "face": "人脸",
+    "noImage": "暂无图片",
     "getDataFailed": "获取数据失败",
     "close": "关闭",
     "resetPasswordTitle": "重置密码",
@@ -859,7 +862,10 @@
     "resetPasswordNewPassword": "的新密码:",
     "resetPasswordPlaceholder": "请输入新密码",
     "assignRoleTitle": "分配角色",
-    "userName": "用户名称"
+    "userName": "用户名称",
+    "uploadFace": "上传人脸照片",
+    "pleaseSelectImage": "请选择要上传的图片",
+    "selectImage": "选择图片"
   },
   "role": {
     "addRole": "新增角色",

+ 13 - 0
src/types/index.ts

@@ -82,3 +82,16 @@ export interface WorkstationNode {
   [key: string]: any;
 }
 
+// 用户特征值(人脸/指纹列表项,与人脸列表接口返回一致)
+export interface UserCharacteristic {
+  id: number;
+  userId: number;
+  type: string;
+  content?: string;
+  createTime?: number | string;
+  imagePath?: string;
+  imageUrl?: string;
+  orderNum?: number;
+  remark?: string | null;
+}
+