Browse Source

💄 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 10 tháng trước cách đây
mục cha
commit
ba7ade4d81

+ 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",