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, AlertCircle,
HelpCircle, HelpCircle,
Coins, Coins,
Tags Tags,
} from 'lucide-react'; } from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
@@ -52,6 +52,7 @@ import {
IconPlus, IconPlus,
IconRefresh, IconRefresh,
IconSetting, IconSetting,
IconDescend,
IconSearch, IconSearch,
IconEdit, IconEdit,
IconDelete, IconDelete,
@@ -64,6 +65,7 @@ import {
import { loadChannelModels } from '../../helpers/index.js'; import { loadChannelModels } from '../../helpers/index.js';
import EditTagModal from '../../pages/Channel/EditTagModal.js'; import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const ChannelsTable = () => { const ChannelsTable = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -683,6 +685,7 @@ const ChannelsTable = () => {
const [typeCounts, setTypeCounts] = useState({}); const [typeCounts, setTypeCounts] = useState({});
const requestCounter = useRef(0); const requestCounter = useRef(0);
const [formApi, setFormApi] = useState(null); const [formApi, setFormApi] = useState(null);
const [compactMode, setCompactMode] = useTableCompactMode('channels');
const formInitValues = { const formInitValues = {
searchKeyword: '', searchKeyword: '',
searchGroup: '', searchGroup: '',
@@ -1576,6 +1579,16 @@ const ChannelsTable = () => {
{t('批量操作')} {t('批量操作')}
</Button> </Button>
</Dropdown> </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>
<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"> <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} bordered={false}
> >
<Table <Table
columns={getVisibleColumns()} columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
dataSource={pageData} dataSource={pageData}
scroll={{ x: 'max-content' }} scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{ pagination={{
currentPage: activePage, currentPage: activePage,
pageSize: pageSize, pageSize: pageSize,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import {
Activity, Activity,
Users, Users,
DollarSign, DollarSign,
UserPlus UserPlus,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Button, Button,
@@ -43,17 +43,20 @@ import {
IconMore, IconMore,
IconUserAdd, IconUserAdd,
IconArrowUp, IconArrowUp,
IconArrowDown IconArrowDown,
IconDescend
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser'; import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser'; import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography; const { Text } = Typography;
const UsersTable = () => { const UsersTable = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('users');
function renderRole(role) { function renderRole(role) {
switch (role) { switch (role) {
@@ -527,9 +530,20 @@ const UsersTable = () => {
const renderHeader = () => ( const renderHeader = () => (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="mb-2"> <div className="mb-2">
<div className="flex items-center text-blue-500"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<IconUserAdd className="mr-2" /> <div className="flex items-center text-blue-500">
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text> <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>
</div> </div>
@@ -645,9 +659,9 @@ const UsersTable = () => {
bordered={false} bordered={false}
> >
<Table <Table
columns={columns} columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
dataSource={users} dataSource={users}
scroll={{ x: 'max-content' }} scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{ pagination={{
formatPageText: (page) => formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { 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 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 React from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants'; import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
import { TABLE_COMPACT_MODES_KEY } from '../constants';
const HTMLToastContent = ({ htmlContent }) => { const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />; return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => {
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`; 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", "按倍率类型筛选": "Filter by ratio type",
"内容": "Content", "内容": "Content",
"放大编辑": "Expand editor", "放大编辑": "Expand editor",
"编辑公告内容": "Edit announcement content" "编辑公告内容": "Edit announcement content",
"自适应列表": "Adaptive list",
"紧凑列表": "Compact list"
} }