Procházet zdrojové kódy

✨ feat(ui): Implement unified compact/adaptive table mode + icon refinement

Summary
• Added per-table “Compact / Adaptive” view toggle to all major table components (Tokens, Channels, Logs, MjLogs, TaskLogs, Redemptions, Users).
• Persist user preference in a single localStorage entry (`table_compact_modes`) instead of scattered keys.

Details
1. utils.js
   • Re-implemented `getTableCompactMode` / `setTableCompactMode` to read & write a shared JSON object.
   • Imported storage-key constant from `constants`.

2. hooks/useTableCompactMode.js
   • Hook now consumes the unified helpers and listens to `storage` events via the shared key constant.

3. constants
   • Added `TABLE_COMPACT_MODES_KEY` to `common.constant.js` and re-exported via `constants/index.js`.

4. Table components
   • Integrated `useTableCompactMode('<tableName>')`.
   • Dynamically remove `fixed: 'right'` column and horizontal `scroll` when in compact mode.
   • UI: toggle button placed at card title’s right; responsive layout on small screens.

5. UI polish
   • Replaced all lucide-react `List`/`ListIcon` usages with Semi UI `IconDescend` for consistency.
   • Restored correct icons where `Hash` was intended (TaskLogsTable).

Benefits
• Consistent UX for switching list density across the app.
• Cleaner localStorage footprint with easier future maintenance.
t0ng7u před 7 měsíci
rodič
revize
014c9450ba

+ 16 - 3
web/src/components/table/ChannelsTable.js

@@ -17,7 +17,7 @@ import {
   AlertCircle,
   HelpCircle,
   Coins,
-  Tags
+  Tags,
 } from 'lucide-react';
 
 import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
@@ -52,6 +52,7 @@ import {
   IconPlus,
   IconRefresh,
   IconSetting,
+  IconDescend,
   IconSearch,
   IconEdit,
   IconDelete,
@@ -64,6 +65,7 @@ import {
 import { loadChannelModels } from '../../helpers/index.js';
 import EditTagModal from '../../pages/Channel/EditTagModal.js';
 import { useTranslation } from 'react-i18next';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const ChannelsTable = () => {
   const { t } = useTranslation();
@@ -683,6 +685,7 @@ const ChannelsTable = () => {
   const [typeCounts, setTypeCounts] = useState({});
   const requestCounter = useRef(0);
   const [formApi, setFormApi] = useState(null);
+  const [compactMode, setCompactMode] = useTableCompactMode('channels');
   const formInitValues = {
     searchKeyword: '',
     searchGroup: '',
@@ -1576,6 +1579,16 @@ const ChannelsTable = () => {
               {t('批量操作')}
             </Button>
           </Dropdown>
+
+          <Button
+            theme='light'
+            type='secondary'
+            icon={<IconDescend />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => setCompactMode(!compactMode)}
+          >
+            {compactMode ? t('自适应列表') : t('紧凑列表')}
+          </Button>
         </div>
 
         <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
@@ -1766,9 +1779,9 @@ const ChannelsTable = () => {
         bordered={false}
       >
         <Table
-          columns={getVisibleColumns()}
+          columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
           dataSource={pageData}
-          scroll={{ x: 'max-content' }}
+          scroll={compactMode ? undefined : { x: 'max-content' }}
           pagination={{
             currentPage: activePage,
             pageSize: pageSize,

+ 97 - 83
web/src/components/table/LogsTable.js

@@ -47,8 +47,9 @@ import {
 } from '@douyinfe/semi-illustrations';
 import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
+import { IconSetting, IconSearch, IconHelpCircle, IconDescend } from '@douyinfe/semi-icons';
 import { Route } from 'lucide-react';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -192,7 +193,7 @@ const LogsTable = () => {
     if (!modelMapped) {
       return renderModelTag(record.model_name, {
         onClick: (event) => {
-          copyText(event, record.model_name).then((r) => {});
+          copyText(event, record.model_name).then((r) => { });
         },
       });
     } else {
@@ -209,7 +210,7 @@ const LogsTable = () => {
                       </Text>
                       {renderModelTag(record.model_name, {
                         onClick: (event) => {
-                          copyText(event, record.model_name).then((r) => {});
+                          copyText(event, record.model_name).then((r) => { });
                         },
                       })}
                     </div>
@@ -220,7 +221,7 @@ const LogsTable = () => {
                       {renderModelTag(other.upstream_model_name, {
                         onClick: (event) => {
                           copyText(event, other.upstream_model_name).then(
-                            (r) => {},
+                            (r) => { },
                           );
                         },
                       })}
@@ -231,7 +232,7 @@ const LogsTable = () => {
             >
               {renderModelTag(record.model_name, {
                 onClick: (event) => {
-                  copyText(event, record.model_name).then((r) => {});
+                  copyText(event, record.model_name).then((r) => { });
                 },
                 suffixIcon: (
                   <Route
@@ -636,23 +637,23 @@ const LogsTable = () => {
         }
         let content = other?.claude
           ? renderClaudeModelPriceSimple(
-              other.model_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              other.cache_tokens || 0,
-              other.cache_ratio || 1.0,
-              other.cache_creation_tokens || 0,
-              other.cache_creation_ratio || 1.0,
-            )
+            other.model_ratio,
+            other.model_price,
+            other.group_ratio,
+            other?.user_group_ratio,
+            other.cache_tokens || 0,
+            other.cache_ratio || 1.0,
+            other.cache_creation_tokens || 0,
+            other.cache_creation_ratio || 1.0,
+          )
           : renderModelPriceSimple(
-              other.model_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              other.cache_tokens || 0,
-              other.cache_ratio || 1.0,
-            );
+            other.model_ratio,
+            other.model_price,
+            other.group_ratio,
+            other?.user_group_ratio,
+            other.cache_tokens || 0,
+            other.cache_ratio || 1.0,
+          );
         return (
           <Paragraph
             ellipsis={{
@@ -985,27 +986,27 @@ const LogsTable = () => {
           key: t('日志详情'),
           value: other?.claude
             ? renderClaudeLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other?.user_group_ratio,
-                other.cache_ratio || 1.0,
-                other.cache_creation_ratio || 1.0,
-              )
+              other?.model_ratio,
+              other.completion_ratio,
+              other.model_price,
+              other.group_ratio,
+              other?.user_group_ratio,
+              other.cache_ratio || 1.0,
+              other.cache_creation_ratio || 1.0,
+            )
             : renderLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other?.user_group_ratio,
-                false,
-                1.0,
-                other.web_search || false,
-                other.web_search_call_count || 0,
-                other.file_search || false,
-                other.file_search_call_count || 0,
-              ),
+              other?.model_ratio,
+              other.completion_ratio,
+              other.model_price,
+              other.group_ratio,
+              other?.user_group_ratio,
+              false,
+              1.0,
+              other.web_search || false,
+              other.web_search_call_count || 0,
+              other.file_search || false,
+              other.file_search_call_count || 0,
+            ),
         });
       }
       if (logs[i].type === 2) {
@@ -1145,7 +1146,7 @@ const LogsTable = () => {
 
   const handlePageChange = (page) => {
     setActivePage(page);
-    loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
+    loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
   };
 
   const handlePageSizeChange = async (size) => {
@@ -1203,6 +1204,8 @@ const LogsTable = () => {
     );
   };
 
+  const [compactMode, setCompactMode] = useTableCompactMode('logs');
+
   return (
     <>
       {renderColumnSelector()}
@@ -1211,45 +1214,57 @@ const LogsTable = () => {
         title={
           <div className='flex flex-col w-full'>
             <Spin spinning={loadingStat}>
-              <Space>
-                <Tag
-                  color='blue'
-                  size='large'
-                  style={{
-                    padding: 15,
-                    borderRadius: '9999px',
-                    fontWeight: 500,
-                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                  }}
-                >
-                  {t('消耗额度')}: {renderQuota(stat.quota)}
-                </Tag>
-                <Tag
-                  color='pink'
-                  size='large'
-                  style={{
-                    padding: 15,
-                    borderRadius: '9999px',
-                    fontWeight: 500,
-                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                  }}
-                >
-                  RPM: {stat.rpm}
-                </Tag>
-                <Tag
-                  color='white'
-                  size='large'
-                  style={{
-                    padding: 15,
-                    border: 'none',
-                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                    borderRadius: '9999px',
-                    fontWeight: 500,
-                  }}
+              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+                <Space>
+                  <Tag
+                    color='blue'
+                    size='large'
+                    style={{
+                      padding: 15,
+                      borderRadius: '9999px',
+                      fontWeight: 500,
+                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    }}
+                  >
+                    {t('消耗额度')}: {renderQuota(stat.quota)}
+                  </Tag>
+                  <Tag
+                    color='pink'
+                    size='large'
+                    style={{
+                      padding: 15,
+                      borderRadius: '9999px',
+                      fontWeight: 500,
+                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    }}
+                  >
+                    RPM: {stat.rpm}
+                  </Tag>
+                  <Tag
+                    color='white'
+                    size='large'
+                    style={{
+                      padding: 15,
+                      border: 'none',
+                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                      borderRadius: '9999px',
+                      fontWeight: 500,
+                    }}
+                  >
+                    TPM: {stat.tpm}
+                  </Tag>
+                </Space>
+
+                <Button
+                  theme='light'
+                  type='secondary'
+                  icon={<IconDescend />}
+                  className="!rounded-full w-full md:w-auto"
+                  onClick={() => setCompactMode(!compactMode)}
                 >
-                  TPM: {stat.tpm}
-                </Tag>
-              </Space>
+                  {compactMode ? t('自适应列表') : t('紧凑列表')}
+                </Button>
+              </div>
             </Spin>
 
             <Divider margin='12px' />
@@ -1382,7 +1397,6 @@ const LogsTable = () => {
                         if (formApi) {
                           formApi.reset();
                           setLogType(0);
-                          // 重置后立即查询,使用setTimeout确保表单重置完成
                           setTimeout(() => {
                             refresh();
                           }, 100);
@@ -1411,7 +1425,7 @@ const LogsTable = () => {
         bordered={false}
       >
         <Table
-          columns={getVisibleColumns()}
+          columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
           {...(hasExpandableRows() && {
             expandedRowRender: expandRowRender,
             expandRowByClick: true,
@@ -1421,7 +1435,7 @@ const LogsTable = () => {
           dataSource={logs}
           rowKey='key'
           loading={loading}
-          scroll={{ x: 'max-content' }}
+          scroll={compactMode ? undefined : { x: 'max-content' }}
           className='rounded-xl overflow-hidden'
           size='middle'
           empty={

+ 17 - 5
web/src/components/table/MjLogsTable.js

@@ -24,7 +24,7 @@ import {
   XCircle,
   Loader,
   AlertCircle,
-  Hash
+  Hash,
 } from 'lucide-react';
 import {
   API,
@@ -59,8 +59,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
 import {
   IconEyeOpened,
   IconSearch,
-  IconSetting
+  IconSetting,
+  IconDescend
 } from '@douyinfe/semi-icons';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -107,6 +109,7 @@ const LogsTable = () => {
   const [visibleColumns, setVisibleColumns] = useState({});
   const [showColumnSelector, setShowColumnSelector] = useState(false);
   const isAdminUser = isAdmin();
+  const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
 
   // 加载保存的列偏好设置
   useEffect(() => {
@@ -802,7 +805,7 @@ const LogsTable = () => {
           className="!rounded-2xl mb-4"
           title={
             <div className="flex flex-col w-full">
-              <div className="flex flex-col md:flex-row justify-between items-center">
+              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
                 <div className="flex items-center text-orange-500 mb-2 md:mb-0">
                   <IconEyeOpened className="mr-2" />
                   {loading ? (
@@ -821,6 +824,15 @@ const LogsTable = () => {
                     </Text>
                   )}
                 </div>
+                <Button
+                  theme='light'
+                  type='secondary'
+                  icon={<IconDescend />}
+                  className="!rounded-full w-full md:w-auto"
+                  onClick={() => setCompactMode(!compactMode)}
+                >
+                  {compactMode ? t('自适应列表') : t('紧凑列表')}
+                </Button>
               </div>
 
               <Divider margin="12px" />
@@ -919,11 +931,11 @@ const LogsTable = () => {
           bordered={false}
         >
           <Table
-            columns={getVisibleColumns()}
+            columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
             dataSource={logs}
             rowKey='key'
             loading={loading}
-            scroll={{ x: 'max-content' }}
+            scroll={compactMode ? undefined : { x: 'max-content' }}
             className="rounded-xl overflow-hidden"
             size="middle"
             empty={

+ 20 - 6
web/src/components/table/RedemptionsTable.js

@@ -45,10 +45,12 @@ import {
   IconDelete,
   IconStop,
   IconPlay,
-  IconMore
+  IconMore,
+  IconDescend
 } from '@douyinfe/semi-icons';
 import EditRedemption from '../../pages/Redemption/EditRedemption';
 import { useTranslation } from 'react-i18next';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -266,6 +268,7 @@ const RedemptionsTable = () => {
     id: undefined,
   });
   const [showEdit, setShowEdit] = useState(false);
+  const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
 
   // Form 初始值
   const formInitValues = {
@@ -465,9 +468,20 @@ const RedemptionsTable = () => {
   const renderHeader = () => (
     <div className="flex flex-col w-full">
       <div className="mb-2">
-        <div className="flex items-center text-orange-500">
-          <Ticket size={16} className="mr-2" />
-          <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
+        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+          <div className="flex items-center text-orange-500">
+            <Ticket size={16} className="mr-2" />
+            <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
+          </div>
+          <Button
+            theme='light'
+            type='secondary'
+            icon={<IconDescend />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => setCompactMode(!compactMode)}
+          >
+            {compactMode ? t('自适应列表') : t('紧凑列表')}
+          </Button>
         </div>
       </div>
 
@@ -610,9 +624,9 @@ const RedemptionsTable = () => {
         bordered={false}
       >
         <Table
-          columns={columns}
+          columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
           dataSource={pageData}
-          scroll={{ x: 'max-content' }}
+          scroll={compactMode ? undefined : { x: 'max-content' }}
           pagination={{
             currentPage: activePage,
             pageSize: pageSize,

+ 17 - 4
web/src/components/table/TaskLogsTable.js

@@ -47,8 +47,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
 import {
   IconEyeOpened,
   IconSearch,
-  IconSetting
+  IconSetting,
+  IconDescend
 } from '@douyinfe/semi-icons';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -471,6 +473,8 @@ const LogsTable = () => {
   const [logs, setLogs] = useState([]);
   const [loading, setLoading] = useState(false);
 
+  const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
+
   useEffect(() => {
     const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
     setPageSize(localPageSize);
@@ -650,7 +654,7 @@ const LogsTable = () => {
           className="!rounded-2xl mb-4"
           title={
             <div className="flex flex-col w-full">
-              <div className="flex flex-col md:flex-row justify-between items-center">
+              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
                 <div className="flex items-center text-orange-500 mb-2 md:mb-0">
                   <IconEyeOpened className="mr-2" />
                   {loading ? (
@@ -665,6 +669,15 @@ const LogsTable = () => {
                     <Text>{t('任务记录')}</Text>
                   )}
                 </div>
+                <Button
+                  theme='light'
+                  type='secondary'
+                  icon={<IconDescend />}
+                  className="!rounded-full w-full md:w-auto"
+                  onClick={() => setCompactMode(!compactMode)}
+                >
+                  {compactMode ? t('自适应列表') : t('紧凑列表')}
+                </Button>
               </div>
 
               <Divider margin="12px" />
@@ -763,11 +776,11 @@ const LogsTable = () => {
           bordered={false}
         >
           <Table
-            columns={getVisibleColumns()}
+            columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
             dataSource={logs}
             rowKey='key'
             loading={loading}
-            scroll={{ x: 'max-content' }}
+            scroll={compactMode ? undefined : { x: 'max-content' }}
             className="rounded-xl overflow-hidden"
             size="middle"
             empty={

+ 27 - 8
web/src/components/table/TokensTable.js

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useMemo } from 'react';
 import {
   API,
   copy,
@@ -52,10 +52,12 @@ import {
   IconDelete,
   IconStop,
   IconPlay,
-  IconMore
+  IconMore,
+  IconDescend
 } from '@douyinfe/semi-icons';
 import EditToken from '../../pages/Token/EditToken';
 import { useTranslation } from 'react-i18next';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -385,6 +387,7 @@ const TokensTable = () => {
   const [editingToken, setEditingToken] = useState({
     id: undefined,
   });
+  const [compactMode, setCompactMode] = useTableCompactMode('tokens');
 
   // Form 初始值
   const formInitValues = {
@@ -610,9 +613,20 @@ const TokensTable = () => {
   const renderHeader = () => (
     <div className="flex flex-col w-full">
       <div className="mb-2">
-        <div className="flex items-center text-blue-500">
-          <Key size={16} className="mr-2" />
-          <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
+        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+          <div className="flex items-center text-blue-500">
+            <Key size={16} className="mr-2" />
+            <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
+          </div>
+          <Button
+            theme="light"
+            type="secondary"
+            icon={<IconDescend />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => setCompactMode(!compactMode)}
+          >
+            {compactMode ? t('自适应列表') : t('紧凑列表')}
+          </Button>
         </div>
       </div>
 
@@ -687,7 +701,6 @@ const TokensTable = () => {
           >
             {t('复制所选令牌')}
           </Button>
-          <div className="w-full md:hidden"></div>
           <Button
             theme="light"
             type="danger"
@@ -791,9 +804,15 @@ const TokensTable = () => {
         bordered={false}
       >
         <Table
-          columns={columns}
+          columns={compactMode ? columns.map(col => {
+            if (col.dataIndex === 'operate') {
+              const { fixed, ...rest } = col;
+              return rest;
+            }
+            return col;
+          }) : columns}
           dataSource={tokens}
-          scroll={{ x: 'max-content' }}
+          scroll={compactMode ? undefined : { x: 'max-content' }}
           pagination={{
             currentPage: activePage,
             pageSize: pageSize,

+ 21 - 7
web/src/components/table/UsersTable.js

@@ -13,7 +13,7 @@ import {
   Activity,
   Users,
   DollarSign,
-  UserPlus
+  UserPlus,
 } from 'lucide-react';
 import {
   Button,
@@ -43,17 +43,20 @@ import {
   IconMore,
   IconUserAdd,
   IconArrowUp,
-  IconArrowDown
+  IconArrowDown,
+  IconDescend
 } from '@douyinfe/semi-icons';
 import { ITEMS_PER_PAGE } from '../../constants';
 import AddUser from '../../pages/User/AddUser';
 import EditUser from '../../pages/User/EditUser';
 import { useTranslation } from 'react-i18next';
+import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 
 const { Text } = Typography;
 
 const UsersTable = () => {
   const { t } = useTranslation();
+  const [compactMode, setCompactMode] = useTableCompactMode('users');
 
   function renderRole(role) {
     switch (role) {
@@ -527,9 +530,20 @@ const UsersTable = () => {
   const renderHeader = () => (
     <div className="flex flex-col w-full">
       <div className="mb-2">
-        <div className="flex items-center text-blue-500">
-          <IconUserAdd className="mr-2" />
-          <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
+        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+          <div className="flex items-center text-blue-500">
+            <IconUserAdd className="mr-2" />
+            <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
+          </div>
+          <Button
+            theme='light'
+            type='secondary'
+            icon={<IconDescend />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => setCompactMode(!compactMode)}
+          >
+            {compactMode ? t('自适应列表') : t('紧凑列表')}
+          </Button>
         </div>
       </div>
 
@@ -645,9 +659,9 @@ const UsersTable = () => {
         bordered={false}
       >
         <Table
-          columns={columns}
+          columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
           dataSource={users}
-          scroll={{ x: 'max-content' }}
+          scroll={compactMode ? undefined : { x: 'max-content' }}
           pagination={{
             formatPageText: (page) =>
               t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

+ 3 - 1
web/src/constants/common.constant.js

@@ -1,3 +1,5 @@
 export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
 
-export const DEFAULT_ENDPOINT = '/api/ratio_config';
+export const DEFAULT_ENDPOINT = '/api/ratio_config';
+
+export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';

+ 29 - 0
web/src/helpers/utils.js

@@ -3,6 +3,7 @@ import { toastConstants } from '../constants';
 import React from 'react';
 import { toast } from 'react-toastify';
 import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
+import { TABLE_COMPACT_MODES_KEY } from '../constants';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => {
   const minutes = String(date.getMinutes()).padStart(2, '0');
   return `${year}-${month}-${day} ${hours}:${minutes}`;
 };
+
+function readTableCompactModes() {
+  try {
+    const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
+    return json ? JSON.parse(json) : {};
+  } catch {
+    return {};
+  }
+}
+
+function writeTableCompactModes(modes) {
+  try {
+    localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
+  } catch {
+    // ignore
+  }
+}
+
+export function getTableCompactMode(tableKey = 'global') {
+  const modes = readTableCompactModes();
+  return !!modes[tableKey];
+}
+
+export function setTableCompactMode(compact, tableKey = 'global') {
+  const modes = readTableCompactModes();
+  modes[tableKey] = compact;
+  writeTableCompactModes(modes);
+}

+ 34 - 0
web/src/hooks/useTableCompactMode.js

@@ -0,0 +1,34 @@
+import { useState, useEffect, useCallback } from 'react';
+import { getTableCompactMode, setTableCompactMode } from '../helpers';
+import { TABLE_COMPACT_MODES_KEY } from '../constants';
+
+/**
+ * 自定义 Hook:管理表格紧凑/自适应模式
+ * 返回 [compactMode, setCompactMode]。
+ * 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。
+ */
+export function useTableCompactMode(tableKey = 'global') {
+    const [compactMode, setCompactModeState] = useState(() => getTableCompactMode(tableKey));
+
+    const setCompactMode = useCallback((value) => {
+        setCompactModeState(value);
+        setTableCompactMode(value, tableKey);
+    }, [tableKey]);
+
+    useEffect(() => {
+        const handleStorage = (e) => {
+            if (e.key === TABLE_COMPACT_MODES_KEY) {
+                try {
+                    const modes = JSON.parse(e.newValue || '{}');
+                    setCompactModeState(!!modes[tableKey]);
+                } catch {
+                    // ignore parse error
+                }
+            }
+        };
+        window.addEventListener('storage', handleStorage);
+        return () => window.removeEventListener('storage', handleStorage);
+    }, [tableKey]);
+
+    return [compactMode, setCompactMode];
+} 

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

@@ -1724,5 +1724,7 @@
   "按倍率类型筛选": "Filter by ratio type",
   "内容": "Content",
   "放大编辑": "Expand editor",
-  "编辑公告内容": "Edit announcement content"
+  "编辑公告内容": "Edit announcement content",
+  "自适应列表": "Adaptive list",
+  "紧凑列表": "Compact list"
 }