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.
This commit is contained in:
t0ng7u
2025-06-22 18:10:00 +08:00
parent d7c97d4d34
commit 014c9450ba
11 changed files with 284 additions and 118 deletions

View File

@@ -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,

View File

@@ -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)',
}}
<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)}
>
{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>
{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={

View File

@@ -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={

View File

@@ -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,

View File

@@ -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={

View File

@@ -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,

View File

@@ -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}} 条', {

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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];
}

View File

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