Jelajahi Sumber

💄 refactor: Users table UI & state handling

Summary of changes
1. UI clean-up
   • Removed all `prefixIcon` props from `Tag` components in `UsersColumnDefs.js`.
   • Corrected i18n string in invite info (`${t('邀请人')}: …`).

2. “Statistics” column overhaul
   • Added a Switch (enable / disable) and quota Progress bar, mirroring the Tokens table design.
   • Moved enable / disable action out of the “More” dropdown; user status is now toggled directly via the Switch.
   • Disabled the Switch for deleted (注销) users.
   • Restored column title to “Statistics” to avoid duplication.

3. State consistency / refresh
   • Updated `manageUser` in `useUsersData.js` to:
     – set `loading` while processing actions;
     – update users list immutably (new objects & array) to trigger React re-render.

4. Imports / plumbing
   • Added `Progress` and `Switch` to UI imports in `UsersColumnDefs.js`.

These changes streamline the user table’s appearance, align interaction patterns with the token table, and ensure immediate visual feedback after user status changes.
t0ng7u 7 bulan lalu
induk
melakukan
39079e7aff

+ 121 - 137
web/src/components/table/users/UsersColumnDefs.js

@@ -20,31 +20,14 @@ For commercial licensing, please contact support@quantumnous.com
 import React from 'react';
 import {
   Button,
-  Dropdown,
   Space,
   Tag,
   Tooltip,
-  Typography
+  Progress,
+  Switch,
 } from '@douyinfe/semi-ui';
-import {
-  User,
-  Shield,
-  Crown,
-  HelpCircle,
-  CheckCircle,
-  XCircle,
-  Minus,
-  Coins,
-  Activity,
-  Users,
-  DollarSign,
-  UserPlus,
-} from 'lucide-react';
-import { IconMore } from '@douyinfe/semi-icons';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 
-const { Text } = Typography;
-
 /**
  * Render user role
  */
@@ -52,53 +35,31 @@ const renderRole = (role, t) => {
   switch (role) {
     case 1:
       return (
-        <Tag color='blue' shape='circle' prefixIcon={<User size={14} />}>
+        <Tag color='blue' shape='circle'>
           {t('普通用户')}
         </Tag>
       );
     case 10:
       return (
-        <Tag color='yellow' shape='circle' prefixIcon={<Shield size={14} />}>
+        <Tag color='yellow' shape='circle'>
           {t('管理员')}
         </Tag>
       );
     case 100:
       return (
-        <Tag color='orange' shape='circle' prefixIcon={<Crown size={14} />}>
+        <Tag color='orange' shape='circle'>
           {t('超级管理员')}
         </Tag>
       );
     default:
       return (
-        <Tag color='red' shape='circle' prefixIcon={<HelpCircle size={14} />}>
+        <Tag color='red' shape='circle'>
           {t('未知身份')}
         </Tag>
       );
   }
 };
 
-/**
- * Render user status
- */
-const renderStatus = (status, t) => {
-  switch (status) {
-    case 1:
-      return <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
-    case 2:
-      return (
-        <Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
-          {t('已封禁')}
-        </Tag>
-      );
-    default:
-      return (
-        <Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-          {t('未知状态')}
-        </Tag>
-      );
-  }
-};
-
 /**
  * Render username with remark
  */
@@ -127,22 +88,91 @@ const renderUsername = (text, record) => {
 /**
  * Render user statistics
  */
-const renderStatistics = (text, record, t) => {
-  return (
-    <div>
-      <Space spacing={1}>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
-          {t('剩余')}: {renderQuota(record.quota)}
-        </Tag>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
-          {t('已用')}: {renderQuota(record.used_quota)}
-        </Tag>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
-          {t('调用')}: {renderNumber(record.request_count)}
-        </Tag>
-      </Space>
+const renderStatistics = (text, record, showEnableDisableModal, t) => {
+  const enabled = record.status === 1;
+  const isDeleted = record.DeletedAt !== null;
+
+  // Determine tag text & color like original status column
+  let tagColor = 'grey';
+  let tagText = t('未知状态');
+  if (isDeleted) {
+    tagColor = 'red';
+    tagText = t('已注销');
+  } else if (record.status === 1) {
+    tagColor = 'green';
+    tagText = t('已激活');
+  } else if (record.status === 2) {
+    tagColor = 'red';
+    tagText = t('已封禁');
+  }
+
+  const handleToggle = (checked) => {
+    if (checked) {
+      showEnableDisableModal(record, 'enable');
+    } else {
+      showEnableDisableModal(record, 'disable');
+    }
+  };
+
+  const used = parseInt(record.used_quota) || 0;
+  const remain = parseInt(record.quota) || 0;
+  const total = used + remain;
+  const percent = total > 0 ? (remain / total) * 100 : 0;
+
+  const getProgressColor = (pct) => {
+    if (pct === 100) return 'var(--semi-color-success)';
+    if (pct <= 10) return 'var(--semi-color-danger)';
+    if (pct <= 30) return 'var(--semi-color-warning)';
+    return undefined;
+  };
+
+  const quotaSuffix = (
+    <div className='flex flex-col items-end'>
+      <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
+      <Progress
+        percent={percent}
+        stroke={getProgressColor(percent)}
+        aria-label='quota usage'
+        format={() => `${percent.toFixed(0)}%`}
+        style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
+      />
     </div>
   );
+
+  const content = (
+    <Tag
+      color={tagColor}
+      shape='circle'
+      size='large'
+      prefixIcon={
+        <Switch
+          size='small'
+          checked={enabled}
+          onChange={handleToggle}
+          disabled={isDeleted}
+          aria-label='user status switch'
+        />
+      }
+      suffixIcon={quotaSuffix}
+    >
+      {tagText}
+    </Tag>
+  );
+
+  const tooltipContent = (
+    <div className='text-xs'>
+      <div>{t('已用额度')}: {renderQuota(used)}</div>
+      <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
+      <div>{t('总额度')}: {renderQuota(total)}</div>
+      <div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
+    </div>
+  );
+
+  return (
+    <Tooltip content={tooltipContent} position='top'>
+      {content}
+    </Tooltip>
+  );
 };
 
 /**
@@ -152,31 +182,20 @@ const renderInviteInfo = (text, record, t) => {
   return (
     <div>
       <Space spacing={1}>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
+        <Tag color='white' shape='circle' className="!text-xs">
           {t('邀请')}: {renderNumber(record.aff_count)}
         </Tag>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
+        <Tag color='white' shape='circle' className="!text-xs">
           {t('收益')}: {renderQuota(record.aff_history_quota)}
         </Tag>
-        <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
-          {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
+        <Tag color='white' shape='circle' className="!text-xs">
+          {record.inviter_id === 0 ? t('无邀请人') : `${t('邀请人')}: ${record.inviter_id}`}
         </Tag>
       </Space>
     </div>
   );
 };
 
-/**
- * Render overall status including deleted status
- */
-const renderOverallStatus = (status, record, t) => {
-  if (record.DeletedAt !== null) {
-    return <Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>;
-  } else {
-    return renderStatus(status, t);
-  }
-};
-
 /**
  * Render operations column
  */
@@ -185,7 +204,6 @@ const renderOperations = (text, record, {
   setShowEditUser,
   showPromoteModal,
   showDemoteModal,
-  showEnableDisableModal,
   showDeleteModal,
   t
 }) => {
@@ -193,46 +211,6 @@ const renderOperations = (text, record, {
     return <></>;
   }
 
-  // Create more operations dropdown menu items
-  const moreMenuItems = [
-    {
-      node: 'item',
-      name: t('提升'),
-      type: 'warning',
-      onClick: () => showPromoteModal(record),
-    },
-    {
-      node: 'item',
-      name: t('降级'),
-      type: 'secondary',
-      onClick: () => showDemoteModal(record),
-    },
-    {
-      node: 'item',
-      name: t('注销'),
-      type: 'danger',
-      onClick: () => showDeleteModal(record),
-    }
-  ];
-
-  // Add enable/disable button dynamically
-  if (record.status === 1) {
-    moreMenuItems.splice(-1, 0, {
-      node: 'item',
-      name: t('禁用'),
-      type: 'warning',
-      onClick: () => showEnableDisableModal(record, 'disable'),
-    });
-  } else {
-    moreMenuItems.splice(-1, 0, {
-      node: 'item',
-      name: t('启用'),
-      type: 'secondary',
-      onClick: () => showEnableDisableModal(record, 'enable'),
-      disabled: record.status === 3,
-    });
-  }
-
   return (
     <Space>
       <Button
@@ -245,17 +223,27 @@ const renderOperations = (text, record, {
       >
         {t('编辑')}
       </Button>
-      <Dropdown
-        trigger='click'
-        position='bottomRight'
-        menu={moreMenuItems}
+      <Button
+        type='warning'
+        size="small"
+        onClick={() => showPromoteModal(record)}
       >
-        <Button
-          type='tertiary'
-          size="small"
-          icon={<IconMore />}
-        />
-      </Dropdown>
+        {t('提升')}
+      </Button>
+      <Button
+        type='secondary'
+        size="small"
+        onClick={() => showDemoteModal(record)}
+      >
+        {t('降级')}
+      </Button>
+      <Button
+        type='danger'
+        size="small"
+        onClick={() => showDeleteModal(record)}
+      >
+        {t('注销')}
+      </Button>
     </Space>
   );
 };
@@ -289,16 +277,6 @@ export const getUsersColumns = ({
         return <div>{renderGroup(text)}</div>;
       },
     },
-    {
-      title: t('统计信息'),
-      dataIndex: 'info',
-      render: (text, record, index) => renderStatistics(text, record, t),
-    },
-    {
-      title: t('邀请信息'),
-      dataIndex: 'invite',
-      render: (text, record, index) => renderInviteInfo(text, record, t),
-    },
     {
       title: t('角色'),
       dataIndex: 'role',
@@ -308,13 +286,19 @@ export const getUsersColumns = ({
     },
     {
       title: t('状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => renderOverallStatus(text, record, t),
+      dataIndex: 'info',
+      render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
+    },
+    {
+      title: t('邀请信息'),
+      dataIndex: 'invite',
+      render: (text, record, index) => renderInviteInfo(text, record, t),
     },
     {
       title: '',
       dataIndex: 'operate',
       fixed: 'right',
+      width: 200,
       render: (text, record, index) => renderOperations(text, record, {
         setEditingUser,
         setShowEditUser,

+ 18 - 12
web/src/hooks/users/useUsersData.js

@@ -121,30 +121,36 @@ export const useUsersData = () => {
 
   // Manage user operations (promote, demote, enable, disable, delete)
   const manageUser = async (userId, action, record) => {
+    // Trigger loading state to force table re-render
+    setLoading(true);
+
     const res = await API.post('/api/user/manage', {
       id: userId,
       action,
     });
+
     const { success, message } = res.data;
     if (success) {
       showSuccess('操作成功完成!');
-      let user = res.data.data;
-      let newUsers = [...users];
-      if (action === 'delete') {
-        // Mark as deleted
-        const index = newUsers.findIndex(u => u.id === userId);
-        if (index > -1) {
-          newUsers[index].DeletedAt = new Date();
+      const user = res.data.data;
+
+      // Create a new array and new object to ensure React detects changes
+      const newUsers = users.map((u) => {
+        if (u.id === userId) {
+          if (action === 'delete') {
+            return { ...u, DeletedAt: new Date() };
+          }
+          return { ...u, status: user.status, role: user.role };
         }
-      } else {
-        // Update status and role
-        record.status = user.status;
-        record.role = user.role;
-      }
+        return u;
+      });
+
       setUsers(newUsers);
     } else {
       showError(message);
     }
+
+    setLoading(false);
   };
 
   // Handle page change

+ 1 - 1
web/src/i18n/locales/en.json

@@ -390,7 +390,6 @@
   "已封禁": "Banned",
   "搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...": "Search user ID, username, display name, and email address...",
   "用户名": "Username",
-  "统计信息": "Statistics",
   "用户角色": "User Role",
   "未绑定邮箱地址": "Email not bound",
   "请求次数": "Number of Requests",
@@ -1483,6 +1482,7 @@
   "剩余": "Remaining",
   "已用": "Used",
   "调用": "Calls",
+  "调用次数": "Call Count",
   "邀请": "Invitations",
   "收益": "Earnings",
   "无邀请人": "No Inviter",