Compare commits

...

7 Commits

10 changed files with 646 additions and 188 deletions

View File

@@ -35,7 +35,7 @@ type Channel struct {
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Tag *string `json:"tag" gorm:"index"`
Setting string `json:"setting" gorm:"type:text"`
Setting *string `json:"setting" gorm:"type:text"`
}
func (channel *Channel) GetModels() []string {
@@ -493,8 +493,8 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
func (channel *Channel) GetSetting() map[string]interface{} {
setting := make(map[string]interface{})
if channel.Setting != "" {
err := json.Unmarshal([]byte(channel.Setting), &setting)
if channel.Setting != nil && *channel.Setting != "" {
err := json.Unmarshal([]byte(*channel.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
}
@@ -508,7 +508,7 @@ func (channel *Channel) SetSetting(setting map[string]interface{}) {
common.SysError("failed to marshal setting: " + err.Error())
return
}
channel.Setting = string(settingBytes)
channel.Setting = common.GetPointer[string](string(settingBytes))
}
func GetChannelsByIds(ids []int) ([]*Channel, error) {

View File

@@ -605,7 +605,7 @@ const ChannelsTable = () => {
<Button type="primary" onClick={() => setShowColumnSelector(false)}>{t('确定')}</Button>
</>
}
style={{ width: 500 }}
style={{ width: isMobile() ? '90%' : 500 }}
bodyStyle={{ padding: '24px' }}
>
<div style={{ marginBottom: 20 }}>
@@ -633,7 +633,11 @@ const ChannelsTable = () => {
}
return (
<div key={column.key} style={{ width: '50%', marginBottom: 16, paddingRight: 8 }}>
<div key={column.key} style={{
width: isMobile() ? '100%' : '50%',
marginBottom: 16,
paddingRight: 8
}}>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={e => handleColumnVisibilityChange(column.key, e.target.checked)}
@@ -1253,87 +1257,137 @@ const ChannelsTable = () => {
<Divider style={{ marginBottom: 15 }} />
<div
style={{
display: isMobile() ? '' : 'flex',
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
marginTop: isMobile() ? 0 : -45,
zIndex: 999,
pointerEvents: 'none'
}}
>
<Space
style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}
style={{
pointerEvents: 'auto',
marginTop: isMobile() ? 0 : 45,
marginBottom: isMobile() ? 16 : 0,
display: 'flex',
flexWrap: isMobile() ? 'wrap' : 'nowrap',
gap: '8px'
}}
>
<Typography.Text strong>{t('使用ID排序')}</Typography.Text>
<Switch
checked={idSort}
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
<div style={{
display: 'flex',
alignItems: 'center',
marginRight: 16,
flexWrap: 'nowrap'
}}>
<Typography.Text strong style={{ marginRight: 8 }}>{t('使用ID排序')}</Typography.Text>
<Switch
checked={idSort}
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
}}
></Switch>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
<Button
theme="light"
type="primary"
icon={<IconPlus />}
onClick={() => {
setEditingChannel({
id: undefined
});
}}
></Switch>
<Button
theme="light"
type="primary"
style={{ marginRight: 8 }}
onClick={() => {
setEditingChannel({
id: undefined
});
setShowEdit(true);
}}
>
{t('添加渠道')}
</Button>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile() ? 'top' : 'top'}
>
<Button theme="light" type="warning" style={{ marginRight: 8 }}>
{t('测试所有通道')}
setShowEdit(true);
}}
>
{t('添加渠道')}
</Button>
</Popconfirm>
<Popconfirm
title={t('确定?')}
okType={'secondary'}
onConfirm={updateAllChannelsBalance}
>
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>
{t('更新所有已启用通道余额')}
<Button
theme="light"
type="primary"
icon={<IconRefresh />}
onClick={refresh}
>
{t('刷新')}
</Button>
</Popconfirm>
<Popconfirm
title={t('确定是否要删除禁用通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={deleteAllDisabledChannels}
>
<Button theme="light" type="danger" style={{ marginRight: 8 }}>
{t('删除禁用通道')}
</Button>
</Popconfirm>
<Button
theme="light"
type="primary"
style={{ marginRight: 8 }}
onClick={refresh}
>
{t('刷新')}
</Button>
<Dropdown
trigger="click"
render={
<Dropdown.Menu>
<Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile() ? 'top' : 'top'}
>
<Button theme="light" type="warning" style={{ width: '100%' }}>
{t('测试所有通道')}
</Button>
</Popconfirm>
</Dropdown.Item>
<Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'secondary'}
onConfirm={updateAllChannelsBalance}
>
<Button theme="light" type="secondary" style={{ width: '100%' }}>
{t('更新所有已启用通道余额')}
</Button>
</Popconfirm>
</Dropdown.Item>
<Dropdown.Item>
<Popconfirm
title={t('确定是否要删除禁用通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={deleteAllDisabledChannels}
>
<Button theme="light" type="danger" style={{ width: '100%' }}>
{t('删除禁用通道')}
</Button>
</Popconfirm>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button theme="light" type="tertiary" icon={<IconSetting />}>
{t('批量操作')}
</Button>
</Dropdown>
</div>
</Space>
</div>
<div style={{ marginTop: 20 }}>
<Space>
<Typography.Text strong>{t('开启批量操作')}</Typography.Text>
<div style={{
marginTop: 20,
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
alignItems: isMobile() ? 'flex-start' : 'center',
gap: isMobile() ? '8px' : '16px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: isMobile() ? 8 : 0
}}>
<Typography.Text strong style={{ marginRight: 8 }}>{t('开启批量操作')}</Typography.Text>
<Switch
label={t('开启批量操作')}
uncheckedText={t('关')}
@@ -1341,20 +1395,25 @@ const ChannelsTable = () => {
onChange={(v) => {
setEnableBatchDelete(v);
}}
></Switch>
/>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
<Popconfirm
title={t('确定是否要删除所选通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={batchDeleteChannels}
disabled={!enableBatchDelete}
position={'top'}
>
<Button
disabled={!enableBatchDelete}
theme="light"
type="danger"
style={{ marginRight: 8 }}
>
{t('删除所选通道')}
</Button>
@@ -1364,17 +1423,27 @@ const ChannelsTable = () => {
content={t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用')}
okType={'warning'}
onConfirm={fixChannelsAbilities}
position={'top'}
>
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>
<Button theme="light" type="secondary">
{t('修复数据库一致性')}
</Button>
</Popconfirm>
</Space>
</div>
</div>
<div style={{ marginTop: 20 }}>
<Space>
<Typography.Text strong>{t('标签聚合模式')}</Typography.Text>
<div style={{
marginTop: 20,
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
alignItems: isMobile() ? 'flex-start' : 'center',
gap: isMobile() ? '8px' : '16px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: isMobile() ? 8 : 0
}}>
<Typography.Text strong style={{ marginRight: 8 }}>{t('标签聚合模式')}</Typography.Text>
<Switch
checked={enableTagMode}
label={t('标签聚合模式')}
@@ -1385,28 +1454,33 @@ const ChannelsTable = () => {
loadChannels(0, pageSize, idSort, v);
}}
/>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
<Button
disabled={!enableBatchDelete}
theme="light"
type="primary"
style={{ marginRight: 8 }}
onClick={() => setShowBatchSetTag(true)}
>
{t('批量设置标签')}
</Button>
<Button
theme="light"
type="tertiary"
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
style={{ marginRight: 8 }}
>
{t('列设置')}
</Button>
</Space>
</div>
</div>
<Table
loading={loading}
columns={getVisibleColumns()}
@@ -1423,6 +1497,7 @@ const ChannelsTable = () => {
},
onPageChange: handlePageChange
}}
expandAllRows={false}
onRow={handleRow}
rowSelection={
enableBatchDelete
@@ -1442,6 +1517,7 @@ const ChannelsTable = () => {
onCancel={() => setShowBatchSetTag(false)}
maskClosable={false}
centered={true}
style={{ width: isMobile() ? '90%' : 500 }}
>
<div style={{ marginBottom: 20 }}>
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
@@ -1450,7 +1526,13 @@ const ChannelsTable = () => {
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
size="large"
/>
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary">
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
</Typography.Text>
</div>
</Modal>
{/* 模型测试弹窗 */}
@@ -1464,7 +1546,6 @@ const ChannelsTable = () => {
footer={null}
maskClosable={true}
centered={true}
width={600}
>
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '10px' }}>
{currentTestChannel && (
@@ -1477,8 +1558,9 @@ const ChannelsTable = () => {
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(value) => setModelSearchKeyword(value)}
onChange={(v) => setModelSearchKeyword(v)}
style={{ marginBottom: '16px' }}
prefix={<IconFilter />}
showClear
/>

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { getFooterHTML, getSystemName } from '../helpers';
import { Layout, Tooltip } from '@douyinfe/semi-ui';
import { StyleContext } from '../context/Style/index.js';
const FooterBar = () => {
const { t } = useTranslation();
const systemName = getSystemName();
const [footer, setFooter] = useState(getFooterHTML());
const [styleState] = useContext(StyleContext);
let remainCheckTimes = 5;
const loadFooter = () => {
@@ -57,7 +59,10 @@ const FooterBar = () => {
}, []);
return (
<div style={{ textAlign: 'center' }}>
<div style={{
textAlign: 'center',
paddingBottom: styleState?.isMobile ? '112px' : '5px',
}}>
{footer ? (
<div
className='custom-footer'

View File

@@ -19,7 +19,10 @@ import {
IconNoteMoneyStroked,
IconPriceTag,
IconUser,
IconLanguage
IconLanguage,
IconInfoCircle,
IconCreditCard,
IconTerminal
} from '@douyinfe/semi-icons';
import { Avatar, Button, Dropdown, Layout, Nav, Switch, Tag } from '@douyinfe/semi-ui';
import { stringToColor } from '../helpers/render';
@@ -27,6 +30,73 @@ import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { StyleContext } from '../context/Style/index.js';
import { StatusContext } from '../context/Status/index.js';
// 自定义顶部栏样式
const headerStyle = {
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)',
borderBottom: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-0)',
transition: 'all 0.3s ease',
width: '100%'
};
// 自定义顶部栏按钮样式
const headerItemStyle = {
borderRadius: '4px',
margin: '0 4px',
transition: 'all 0.3s ease'
};
// 自定义顶部栏按钮悬停样式
const headerItemHoverStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)'
};
// 自定义顶部栏Logo样式
const logoStyle = {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '0 10px',
height: '100%'
};
// 自定义顶部栏系统名称样式
const systemNameStyle = {
fontWeight: 'bold',
fontSize: '18px',
background: 'linear-gradient(45deg, var(--semi-color-primary), var(--semi-color-secondary))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
padding: '0 5px'
};
// 自定义顶部栏按钮图标样式
const headerIconStyle = {
fontSize: '18px',
transition: 'all 0.3s ease'
};
// 自定义头像样式
const avatarStyle = {
margin: '4px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease'
};
// 自定义下拉菜单样式
const dropdownStyle = {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden'
};
// 自定义主题切换开关样式
const switchStyle = {
margin: '0 8px'
};
const HeaderBar = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
@@ -52,16 +122,19 @@ const HeaderBar = () => {
text: t('首页'),
itemKey: 'home',
to: '/',
icon: <IconHome style={headerIconStyle} />,
},
{
text: t('控制台'),
itemKey: 'detail',
to: '/',
icon: <IconTerminal style={headerIconStyle} />,
},
{
text: t('定价'),
itemKey: 'pricing',
to: '/pricing',
icon: <IconPriceTag style={headerIconStyle} />,
},
// Only include the docs button if docsLink exists
...(docsLink ? [{
@@ -69,11 +142,13 @@ const HeaderBar = () => {
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
icon: <IconHelpCircle style={headerIconStyle} />,
}] : []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
icon: <IconInfoCircle style={headerIconStyle} />,
},
];
@@ -143,6 +218,9 @@ const HeaderBar = () => {
<Nav
className={'topnav'}
mode={'horizontal'}
style={headerStyle}
itemStyle={headerItemStyle}
hoverStyle={headerItemHoverStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
const routerMap = {
about: '/about',
@@ -224,11 +302,13 @@ const HeaderBar = () => {
),
}:{
logo: (
<img src={logo} alt='logo' />
<div style={logoStyle}>
<img src={logo} alt='logo' style={{ height: '28px' }} />
</div>
),
text: (
<div style={{ position: 'relative', display: 'inline-block' }}>
{systemName}
<span style={systemNameStyle}>{systemName}</span>
{(isSelfUseMode || isDemoSiteMode) && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
@@ -257,7 +337,7 @@ const HeaderBar = () => {
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={handleNewYearClick}>
Happy New Year!!!
</Dropdown.Item>
@@ -274,6 +354,7 @@ const HeaderBar = () => {
size={styleState.isMobile?'default':'large'}
checked={theme === 'dark'}
uncheckedText='🌙'
style={switchStyle}
onChange={(checked) => {
setTheme(checked);
}}
@@ -282,7 +363,7 @@ const HeaderBar = () => {
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
type={currentLang === 'zh' ? 'primary' : 'tertiary'}
@@ -300,7 +381,7 @@ const HeaderBar = () => {
>
<Nav.Item
itemKey={'language'}
icon={<IconLanguage />}
icon={<IconLanguage style={headerIconStyle} />}
/>
</Dropdown>
{userState.user ? (
@@ -308,7 +389,7 @@ const HeaderBar = () => {
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={logout}>{t('退出')}</Dropdown.Item>
</Dropdown.Menu>
}
@@ -316,11 +397,11 @@ const HeaderBar = () => {
<Avatar
size='small'
color={stringToColor(userState.user.username)}
style={{ margin: 4 }}
style={avatarStyle}
>
{userState.user.username[0]}
</Avatar>
{styleState.isMobile?null:<Text>{userState.user.username}</Text>}
{styleState.isMobile?null:<Text style={{ marginLeft: '4px', fontWeight: '500' }}>{userState.user.username}</Text>}
</Dropdown>
</>
) : (
@@ -328,7 +409,7 @@ const HeaderBar = () => {
<Nav.Item
itemKey={'login'}
text={!styleState.isMobile?t('登录'):null}
icon={<IconUser />}
icon={<IconUser style={headerIconStyle} />}
/>
{
// Hide register option in self-use mode
@@ -336,7 +417,7 @@ const HeaderBar = () => {
<Nav.Item
itemKey={'register'}
text={t('注册')}
icon={<IconKey />}
icon={<IconKey style={headerIconStyle} />}
/>
)
}

View File

@@ -459,7 +459,7 @@ const LogsTable = () => {
<>
<Space>
{renderUseTime(text)}
{renderFirstUseTime(other.frt)}
{renderFirstUseTime(other?.frt)}
{renderIsStream(record.is_stream)}
</Space>
</>
@@ -837,29 +837,29 @@ const LogsTable = () => {
let content = '';
if (other?.ws || other?.audio) {
content = renderAudioModelPrice(
other.text_input,
other.text_output,
other.model_ratio,
other.model_price,
other.completion_ratio,
other.audio_input,
other.audio_output,
other?.text_input,
other?.text_output,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.audio_input,
other?.audio_output,
other?.audio_ratio,
other?.audio_completion_ratio,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other?.group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
);
} else {
content = renderModelPrice(
logs[i].prompt_tokens,
logs[i].completion_tokens,
other.model_ratio,
other.model_price,
other.completion_ratio,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
);
}
expandDataLocal.push({
@@ -958,16 +958,32 @@ const LogsTable = () => {
<>
{renderColumnSelector()}
<Layout>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Header>
<Spin spinning={loadingStat}>
<Space>
<Tag color='green' size='large' style={{ padding: 15 }}>
{t('总消耗额度')}: {renderQuota(stat.quota)}
<Tag color='blue' size='large' style={{
padding: 15,
borderRadius: '8px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
}}>
{t('消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag color='blue' size='large' style={{ padding: 15 }}>
<Tag color='pink' size='large' style={{
padding: 15,
borderRadius: '8px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
}}>
RPM: {stat.rpm}
</Tag>
<Tag color='purple' size='large' style={{ padding: 15 }}>
<Tag color='white' size='large' style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '8px',
fontWeight: 500,
}}>
TPM: {stat.tpm}
</Tag>
</Space>

View File

@@ -62,24 +62,79 @@ const PageLayout = () => {
if (savedLang) {
i18n.changeLanguage(savedLang);
}
// 默认显示侧边栏
styleDispatch({ type: 'SET_SIDER', payload: true });
}, [i18n]);
// 获取侧边栏折叠状态
const isSidebarCollapsed = localStorage.getItem('default_collapse_sidebar') === 'true';
return (
<Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Header>
<Layout style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Header style={{
padding: 0,
height: 'auto',
lineHeight: 'normal',
position: 'fixed',
width: '100%',
top: 0,
zIndex: 100,
boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)'
}}>
<HeaderBar />
</Header>
<Layout style={{ flex: 1, overflow: 'hidden' }}>
<Sider>
{styleState.showSider ? <SiderBar /> : null}
</Sider>
<Layout>
<Layout style={{
marginTop: '56px',
height: 'calc(100vh - 56px)',
overflow: 'auto',
display: 'flex',
flexDirection: 'column'
}}>
{styleState.showSider && (
<Sider style={{
position: 'fixed',
left: 0,
top: '56px',
zIndex: 99,
background: 'var(--semi-color-bg-1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
border: 'none',
paddingRight: '0',
transition: 'transform 0.3s ease',
height: 'calc(100vh - 56px)',
}}>
<SiderBar />
</Sider>
)}
<Layout style={{
marginLeft: styleState.isMobile ? '0' : (styleState.showSider ? (isSidebarCollapsed ? '60px' : '200px') : '0'),
transition: 'margin-left 0.3s ease',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column'
}}>
<Content
style={{ overflowY: 'auto', padding: styleState.shouldInnerPadding? '24px': '0' }}
style={{
flex: '1 0 auto',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
padding: styleState.shouldInnerPadding? '24px': '0',
position: 'relative',
paddingBottom: styleState.isMobile ? '80px' : '0' // 移动端底部额外内边距
}}
>
<App />
</Content>
<Layout.Footer>
<Layout.Footer style={{
flex: '0 0 auto',
width: '100%'
}}>
<FooterBar />
</Layout.Footer>
</Layout>

View File

@@ -1,5 +1,5 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../context/User';
import { StatusContext } from '../context/Status';
import { useTranslation } from 'react-i18next';
@@ -33,8 +33,36 @@ import { setStatusData } from '../helpers/data.js';
import { stringToColor } from '../helpers/render.js';
import { useSetTheme, useTheme } from '../context/Theme/index.js';
import { StyleContext } from '../context/Style/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
// HeaderBar Buttons
// 自定义侧边栏按钮样式
const navItemStyle = {
borderRadius: '6px',
margin: '4px 8px',
transition: 'all 0.3s ease'
};
// 自定义侧边栏按钮悬停样式
const navItemHoverStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)'
};
// 自定义侧边栏按钮选中样式
const navItemSelectedStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
fontWeight: '600'
};
// 自定义图标样式
const iconStyle = (itemKey, selectedKeys) => {
return {
fontSize: '18px',
color: selectedKeys.includes(itemKey) ? 'var(--semi-color-primary)' : 'var(--semi-color-text-2)',
transition: 'all 0.3s ease'
};
};
const SiderBar = () => {
const { t } = useTranslation();
@@ -46,8 +74,30 @@ const SiderBar = () => {
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const theme = useTheme();
const setTheme = useSetTheme();
const location = useLocation();
// 预先计算所有可能的图标样式
const allItemKeys = useMemo(() => {
const keys = ['home', 'channel', 'token', 'redemption', 'topup', 'user', 'log', 'midjourney',
'setting', 'about', 'chat', 'detail', 'pricing', 'task', 'playground', 'personal'];
// 添加聊天项的keys
for (let i = 0; i < chatItems.length; i++) {
keys.push('chat' + i);
}
return keys;
}, [chatItems]);
// 使用useMemo一次性计算所有图标样式
const iconStyles = useMemo(() => {
const styles = {};
allItemKeys.forEach(key => {
styles[key] = iconStyle(key, selectedKeys);
});
return styles;
}, [allItemKeys, selectedKeys]);
const routerMap = {
home: '/',
@@ -190,11 +240,14 @@ const SiderBar = () => {
);
useEffect(() => {
let localKey = window.location.pathname.split('/')[1];
if (localKey === '') {
localKey = 'home';
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMap).find(key => routerMap[key] === currentPath);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/chat/')) {
setSelectedKeys(['chat']);
}
setSelectedKeys([localKey]);
let chats = localStorage.getItem('chats');
if (chats) {
@@ -222,7 +275,7 @@ const SiderBar = () => {
}
setIsCollapsed(localStorage.getItem('default_collapse_sidebar') === 'true');
}, []);
}, [location.pathname]);
// Custom divider style
const dividerStyle = {
@@ -235,21 +288,56 @@ const SiderBar = () => {
padding: '8px 16px',
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'normal',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.5px',
};
return (
<>
<Nav
style={{ maxWidth: 200, height: '100%' }}
className="custom-sidebar-nav"
style={{
width: isCollapsed ? '60px' : '200px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
borderRight: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-1)',
borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
transition: 'all 0.3s ease',
position: 'relative',
zIndex: 95,
height: '100%',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
}}
defaultIsCollapsed={
localStorage.getItem('default_collapse_sidebar') === 'true'
}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
localStorage.setItem('default_collapse_sidebar', collapsed);
// 始终保持侧边栏显示,只是宽度不同
styleDispatch({ type: 'SET_SIDER', payload: true });
// 确保在收起侧边栏时有选中的项目,避免不必要的计算
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMap).find(key => routerMap[key] === currentPath);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['home']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle={navItemStyle}
hoverStyle={navItemHoverStyle}
selectedStyle={navItemSelectedStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
let chats = localStorage.getItem('chats');
if (chats) {
@@ -284,8 +372,18 @@ const SiderBar = () => {
} else {
styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
}
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter(k => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* Chat Section - Only show if there are chat items */}
{chatMenuItems.map((item) => {
@@ -295,7 +393,7 @@ const SiderBar = () => {
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={item.icon}
icon={React.cloneElement(item.icon, { style: iconStyles[item.itemKey] })}
>
{item.items.map((subItem) => (
<Nav.Item
@@ -312,39 +410,23 @@ const SiderBar = () => {
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={item.icon}
icon={React.cloneElement(item.icon, { style: iconStyles[item.itemKey] })}
/>
);
}
})}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Workspace Section */}
{!isCollapsed && <div style={groupLabelStyle}>{t('控制台')}</div>}
{!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
{workspaceItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={item.icon}
className={item.className}
/>
))}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Finance Management Section */}
{!isCollapsed && <div style={groupLabelStyle}>{t('个人中心')}</div>}
{financeItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={item.icon}
icon={React.cloneElement(item.icon, { style: iconStyles[item.itemKey] })}
className={item.className}
/>
))}
@@ -355,19 +437,38 @@ const SiderBar = () => {
<Divider style={dividerStyle} />
{/* Admin Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
{adminItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={item.icon}
icon={React.cloneElement(item.icon, { style: iconStyles[item.itemKey] })}
className={item.className}
/>
))}
</>
)}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Finance Management Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
{financeItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, { style: iconStyles[item.itemKey] })}
className={item.className}
/>
))}
<Nav.Footer
style={{
paddingBottom: styleState?.isMobile ? '112px' : '0',
}}
collapseButton={true}
collapseText={(collapsed)=>
{

View File

@@ -9,7 +9,7 @@ export const StyleContext = React.createContext({
export const StyleProvider = ({ children }) => {
const [state, setState] = useState({
isMobile: false,
isMobile: isMobile(),
showSider: false,
shouldInnerPadding: false,
});
@@ -39,7 +39,13 @@ export const StyleProvider = ({ children }) => {
useEffect(() => {
const updateIsMobile = () => {
dispatch({ type: 'SET_MOBILE', payload: isMobile() });
const mobileDetected = isMobile();
dispatch({ type: 'SET_MOBILE', payload: mobileDetected });
// If on mobile, we might want to auto-hide the sidebar
if (mobileDetected && state.showSider) {
dispatch({ type: 'SET_SIDER', payload: false });
}
};
updateIsMobile();
@@ -51,24 +57,24 @@ export const StyleProvider = ({ children }) => {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
dispatch({ type: 'SET_SIDER', payload: true });
// Only show sidebar on non-mobile devices by default
dispatch({ type: 'SET_SIDER', payload: !isMobile() });
dispatch({ type: 'SET_INNER_PADDING', payload: true });
}
if (isMobile()) {
dispatch({ type: 'SET_SIDER', payload: false });
}
};
updateShowSider()
updateShowSider();
// Optionally, add event listeners to handle window resize
window.addEventListener('resize', updateIsMobile);
// Add event listeners to handle window resize
const handleResize = () => {
updateIsMobile();
};
window.addEventListener('resize', handleResize);
// Cleanup event listener on component unmount
return () => {
window.removeEventListener('resize', updateIsMobile);
window.removeEventListener('resize', handleResize);
};
}, []);

View File

@@ -1,7 +1,7 @@
body {
margin: 0;
padding-top: 55px;
overflow-y: scroll;
padding-top: 0;
overflow: hidden;
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei',
sans-serif;
-webkit-font-smoothing: antialiased;
@@ -15,6 +15,7 @@ body {
#root {
height: 100vh;
flex-direction: column;
overflow: hidden;
}
#root > section > header > section > div > div > div > div.semi-navigation-header-list-outer > div.semi-navigation-list-wrapper > ul > div > a > li > span{
@@ -29,6 +30,15 @@ body {
/*.semi-navigation-sub-wrap .semi-navigation-sub-title, .semi-navigation-item {*/
/* padding: 0 0;*/
/*}*/
.topnav {
padding: 0 8px;
}
.topnav .semi-navigation-item {
margin: 0 1px;
padding: 0 4px;
}
.topnav .semi-navigation-list-wrapper {
max-width: calc(55vw - 20px);
overflow-x: auto;
@@ -72,6 +82,16 @@ body {
.semi-navigation-horizontal .semi-navigation-header {
margin-right: 0;
}
/* 确保移动端内容可滚动 */
.semi-layout-content {
-webkit-overflow-scrolling: touch !important;
}
/* 隐藏在移动设备上 */
.hide-on-mobile {
display: none !important;
}
}
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
@@ -120,15 +140,47 @@ code {
margin-bottom: 0;
}
.semi-navigation-vertical {
/*flex: 0 0 auto;*/
/*display: flex;*/
/*flex-direction: column;*/
/*width: 100%;*/
height: 100%;
/* 自定义侧边栏按钮悬停效果 */
.semi-navigation-item:hover {
transform: translateX(2px);
box-shadow: 0 2px 8px rgba(var(--semi-color-primary-rgb), 0.2);
}
/* 自定义侧边栏按钮选中效果 */
.semi-navigation-item-selected {
position: relative;
overflow: hidden;
}
.semi-navigation-item-selected::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background-color: var(--semi-color-primary);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
/*.semi-navigation-vertical {*/
/* !*flex: 0 0 auto;*!*/
/* !*display: flex;*!*/
/* !*flex-direction: column;*!*/
/* !*width: 100%;*!*/
/* height: 100%;*/
/* overflow: hidden;*/
/*}*/
.main-content {
padding: 4px;
height: 100%;
@@ -142,8 +194,67 @@ code {
font-size: 1.1em;
}
@media only screen and (max-width: 600px) {
.hide-on-mobile {
display: none !important;
}
/* 顶部栏样式 */
.topnav {
padding: 0 16px;
}
.topnav .semi-navigation-item {
border-radius: 4px;
margin: 0 2px;
transition: all 0.3s ease;
}
.topnav .semi-navigation-item:hover {
background-color: var(--semi-color-primary-light-default);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(var(--semi-color-primary-rgb), 0.2);
}
.topnav .semi-navigation-item-selected {
background-color: var(--semi-color-primary-light-default);
color: var(--semi-color-primary);
font-weight: 600;
}
/* 顶部栏文本样式 */
.header-bar-text {
color: var(--semi-color-text-0);
font-weight: 500;
transition: all 0.3s ease;
}
.header-bar-text:hover {
color: var(--semi-color-primary);
}
/* 自定义滚动条样式 */
.semi-layout-content::-webkit-scrollbar,
.semi-sider::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.semi-layout-content::-webkit-scrollbar-thumb,
.semi-sider::-webkit-scrollbar-thumb {
background: var(--semi-color-tertiary-light-default);
border-radius: 3px;
}
.semi-layout-content::-webkit-scrollbar-thumb:hover,
.semi-sider::-webkit-scrollbar-thumb:hover {
background: var(--semi-color-tertiary);
}
.semi-layout-content::-webkit-scrollbar-track,
.semi-sider::-webkit-scrollbar-track {
background: transparent;
}
/* Custom sidebar shadow */
/*.custom-sidebar-nav {*/
/* box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08) !important;*/
/* -webkit-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08) !important;*/
/* -moz-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08) !important;*/
/* min-height: 100%;*/
/*}*/

View File

@@ -52,6 +52,7 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',