mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat: improve PageEditor header UX with DropdownMenu and i18n support (#11462)
* ✨ feat: improve PageEditor header UX with DropdownMenu and i18n support - Migrate Header from Dropdown to DropdownMenu component with checkbox support - Add i18n for Ask Copilot item using common cmdk.askLobeAI key - Replace BotIcon with Avatar using DEFAULT_INBOX_AVATAR - Add hideWhenExpanded prop to ToggleRightPanelButton - Conditionally show page info section only when lastUpdatedTime exists * 🔧 chore: update @lobehub/ui dependency to version 4.18.0 in package.json * feat: unify proxy setting style Signed-off-by: Innei <tukon479@gmail.com> * fix: test Signed-off-by: Innei <tukon479@gmail.com> * fix: test Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -206,7 +206,7 @@
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
"@lobehub/market-sdk": "0.28.1",
|
||||
"@lobehub/tts": "^4.0.2",
|
||||
"@lobehub/ui": "^4.11.6",
|
||||
"@lobehub/ui": "^4.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@next/third-parties": "^16.1.1",
|
||||
@@ -454,4 +454,4 @@
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Center, Empty, Markdown } from '@lobehub/ui';
|
||||
import { FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { type ReactNode, memo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { H1, H2, H3, H4, H5 } from './Toc/Heading';
|
||||
@@ -26,7 +26,7 @@ const MarkdownRender = memo<{ children?: string }>(({ children }) => {
|
||||
<Markdown
|
||||
allowHtml
|
||||
components={{
|
||||
a: ({ href, ...rest }: { children: ReactNode; href: string }) => {
|
||||
a: ({ href, ...rest }) => {
|
||||
if (href && href.startsWith('http'))
|
||||
return <Link {...rest} href={href} target={'_blank'} />;
|
||||
return rest?.children;
|
||||
@@ -36,12 +36,14 @@ const MarkdownRender = memo<{ children?: string }>(({ children }) => {
|
||||
h3: H3,
|
||||
h4: H4,
|
||||
h5: H5,
|
||||
img: ({ src, ...rest }: { src: string }) => {
|
||||
if (src.includes('glama.ai')) return;
|
||||
img: ({ src, ...rest }) => {
|
||||
// FIXME ignore experimental blob image prop passing
|
||||
if (typeof src !== 'string') return null;
|
||||
if (src.includes('glama.ai')) return null;
|
||||
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
if (src && src.startsWith('http')) return <img src={src} {...rest} />;
|
||||
return;
|
||||
if (src.startsWith('http')) return <img src={src} {...rest} />;
|
||||
return null;
|
||||
},
|
||||
}}
|
||||
enableImageGallery={false}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { Alert, Block, Flexbox, Skeleton, Text , Button } from '@lobehub/ui';
|
||||
import { App, Divider, Form, Input, Radio, Space, Switch } from 'antd';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { Alert, Flexbox, Form, type FormGroupItemType, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { Form as AntdForm, Button, Input, Radio, Space, Switch } from 'antd';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||
import { desktopSettingsService } from '@/services/electron/settings';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
@@ -19,15 +20,15 @@ interface ProxyTestResult {
|
||||
const ProxyForm = () => {
|
||||
const { t } = useTranslation('electron');
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
const [testUrl, setTestUrl] = useState('https://www.google.com');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isEnableProxy = Form.useWatch('enableProxy', form);
|
||||
const proxyRequireAuth = Form.useWatch('proxyRequireAuth', form);
|
||||
const isEnableProxy = AntdForm.useWatch('enableProxy', form);
|
||||
const proxyRequireAuth = AntdForm.useWatch('proxyRequireAuth', form);
|
||||
|
||||
const [setProxySettings, useGetProxySettings] = useElectronStore((s) => [
|
||||
s.setProxySettings,
|
||||
@@ -44,19 +45,12 @@ const ProxyForm = () => {
|
||||
|
||||
// 监听表单变化
|
||||
const handleValuesChange = useCallback(() => {
|
||||
setLoading(true);
|
||||
setHasUnsavedChanges(true);
|
||||
setTestResult(null); // 清除之前的测试结果
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const updateFormValue = (value: any) => {
|
||||
const preValues = form.getFieldsValue();
|
||||
form.setFieldsValue(value);
|
||||
const newValues = form.getFieldsValue();
|
||||
if (isEqual(newValues, preValues)) return;
|
||||
|
||||
handleValuesChange();
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,15 +58,12 @@ const ProxyForm = () => {
|
||||
const values = await form.validateFields();
|
||||
await setProxySettings(values);
|
||||
setHasUnsavedChanges(false);
|
||||
message.success(t('proxy.saveSuccess'));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(t('proxy.saveFailed', { error: error.message }));
|
||||
}
|
||||
} catch {
|
||||
// validation error
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [form, t, message]);
|
||||
}, [form, setProxySettings]);
|
||||
|
||||
// 重置配置
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -107,240 +98,159 @@ const ProxyForm = () => {
|
||||
success: false,
|
||||
};
|
||||
setTestResult(result);
|
||||
message.error(t('proxy.testFailed'));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [proxySettings, testUrl]);
|
||||
}, [proxySettings, testUrl, form]);
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (isLoading) return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
|
||||
|
||||
const enableProxyGroup: FormGroupItemType = {
|
||||
children: [
|
||||
{
|
||||
children: <Switch />,
|
||||
desc: t('proxy.enableDesc'),
|
||||
label: t('proxy.enable'),
|
||||
layout: 'horizontal',
|
||||
minWidth: undefined,
|
||||
name: 'enableProxy',
|
||||
valuePropName: 'checked',
|
||||
},
|
||||
],
|
||||
extra: loading && <Icon icon={Loader2Icon} size={16} spin style={{ opacity: 0.5 }} />,
|
||||
title: t('proxy.enable'),
|
||||
};
|
||||
|
||||
const basicSettingsGroup: FormGroupItemType = {
|
||||
children: [
|
||||
{
|
||||
children: (
|
||||
<Radio.Group disabled={!isEnableProxy}>
|
||||
<Radio value="http">HTTP</Radio>
|
||||
<Radio value="https">HTTPS</Radio>
|
||||
<Radio value="socks5">SOCKS5</Radio>
|
||||
</Radio.Group>
|
||||
),
|
||||
label: t('proxy.type'),
|
||||
minWidth: undefined,
|
||||
name: 'proxyType',
|
||||
},
|
||||
{
|
||||
children: <Input disabled={!isEnableProxy} placeholder="127.0.0.1" />,
|
||||
desc: t('proxy.validation.serverRequired'),
|
||||
label: t('proxy.server'),
|
||||
name: 'proxyServer',
|
||||
},
|
||||
{
|
||||
children: <Input disabled={!isEnableProxy} placeholder="7890" style={{ width: 120 }} />,
|
||||
desc: t('proxy.validation.portRequired'),
|
||||
label: t('proxy.port'),
|
||||
name: 'proxyPort',
|
||||
},
|
||||
],
|
||||
extra: loading && <Icon icon={Loader2Icon} size={16} spin style={{ opacity: 0.5 }} />,
|
||||
title: t('proxy.basicSettings'),
|
||||
};
|
||||
|
||||
const authGroup: FormGroupItemType = {
|
||||
children: [
|
||||
{
|
||||
children: <Switch disabled={!isEnableProxy} />,
|
||||
desc: t('proxy.authDesc'),
|
||||
label: t('proxy.auth'),
|
||||
layout: 'horizontal',
|
||||
minWidth: undefined,
|
||||
name: 'proxyRequireAuth',
|
||||
valuePropName: 'checked',
|
||||
},
|
||||
...(proxyRequireAuth && isEnableProxy
|
||||
? [
|
||||
{
|
||||
children: <Input placeholder={t('proxy.username_placeholder')} />,
|
||||
label: t('proxy.username'),
|
||||
name: 'proxyUsername',
|
||||
},
|
||||
{
|
||||
children: <Input.Password placeholder={t('proxy.password_placeholder')} />,
|
||||
label: t('proxy.password'),
|
||||
name: 'proxyPassword',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
extra: loading && <Icon icon={Loader2Icon} size={16} spin style={{ opacity: 0.5 }} />,
|
||||
title: t('proxy.authSettings'),
|
||||
};
|
||||
|
||||
const testGroup: FormGroupItemType = {
|
||||
children: [
|
||||
{
|
||||
children: (
|
||||
<Flexbox gap={8}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
placeholder={t('proxy.testUrlPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={testUrl}
|
||||
/>
|
||||
<Button loading={isTesting} onClick={handleTest} type="default">
|
||||
{t('proxy.testButton')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{/* 测试结果显示 */}
|
||||
{!testResult ? null : testResult.success ? (
|
||||
<Alert
|
||||
closable
|
||||
title={
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
{t('proxy.testSuccessWithTime', { time: testResult.responseTime })}
|
||||
</Flexbox>
|
||||
}
|
||||
type={'success'}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
closable
|
||||
title={
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
{t('proxy.testFailed')}: {testResult.message}
|
||||
</Flexbox>
|
||||
}
|
||||
type={'error'}
|
||||
variant={'outlined'}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
),
|
||||
desc: t('proxy.testDescription'),
|
||||
label: t('proxy.testUrl'),
|
||||
minWidth: undefined,
|
||||
},
|
||||
],
|
||||
extra: loading && <Icon icon={Loader2Icon} size={16} spin style={{ opacity: 0.5 }} />,
|
||||
title: t('proxy.connectionTest'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
disabled={isSaving}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleValuesChange}
|
||||
requiredMark={false}
|
||||
>
|
||||
<Flexbox gap={24}>
|
||||
{/* 基本代理设置 */}
|
||||
<Block
|
||||
paddingBlock={16}
|
||||
paddingInline={24}
|
||||
style={{ borderRadius: 12 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Form.Item name="enableProxy" noStyle valuePropName="checked">
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<Flexbox>
|
||||
<Text as={'h4'}>{t('proxy.enable')}</Text>
|
||||
<Text type={'secondary'}>{t('proxy.enableDesc')}</Text>
|
||||
</Flexbox>
|
||||
<Switch
|
||||
checked={isEnableProxy}
|
||||
onChange={(checked) => {
|
||||
updateFormValue({ enableProxy: checked });
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Form.Item>
|
||||
</Block>
|
||||
|
||||
{/* 认证设置 */}
|
||||
<Block
|
||||
paddingBlock={16}
|
||||
paddingInline={24}
|
||||
style={{ borderRadius: 12 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox gap={24}>
|
||||
<Flexbox>
|
||||
<Text as={'h4'}>{t('proxy.basicSettings')}</Text>
|
||||
<Text type={'secondary'}>{t('proxy.basicSettingsDesc')}</Text>
|
||||
</Flexbox>
|
||||
<Flexbox>
|
||||
<Form.Item
|
||||
dependencies={['enableProxy']}
|
||||
label={t('proxy.type')}
|
||||
name="proxyType"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
message: t('proxy.validation.typeRequired'),
|
||||
required: getFieldValue('enableProxy'),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Radio.Group disabled={!form.getFieldValue('enableProxy')}>
|
||||
<Radio value="http">HTTP</Radio>
|
||||
<Radio value="https">HTTPS</Radio>
|
||||
<Radio value="socks5">SOCKS5</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
dependencies={['enableProxy']}
|
||||
label={t('proxy.server')}
|
||||
name="proxyServer"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
message: t('proxy.validation.serverRequired'),
|
||||
required: getFieldValue('enableProxy'),
|
||||
}),
|
||||
{
|
||||
message: t('proxy.validation.serverInvalid'),
|
||||
pattern:
|
||||
/^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$|^[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?(\.[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?)*$/,
|
||||
},
|
||||
]}
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Input disabled={!form.getFieldValue('enableProxy')} placeholder="127.0.0.1" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
dependencies={['enableProxy']}
|
||||
label={t('proxy.port')}
|
||||
name="proxyPort"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
message: t('proxy.validation.portRequired'),
|
||||
required: getFieldValue('enableProxy'),
|
||||
}),
|
||||
{
|
||||
message: t('proxy.validation.portInvalid'),
|
||||
pattern:
|
||||
/^([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/,
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 0, width: 120 }}
|
||||
>
|
||||
<Input disabled={!form.getFieldValue('enableProxy')} placeholder="7890" />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Flexbox>
|
||||
<Divider size={'small'} />
|
||||
<Flexbox gap={12}>
|
||||
<Form.Item
|
||||
dependencies={['enableProxy']}
|
||||
name="proxyRequireAuth"
|
||||
noStyle
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<Flexbox>
|
||||
<Text as={'h5'}>{t('proxy.auth')}</Text>
|
||||
<Text type={'secondary'}>{t('proxy.authDesc')}</Text>
|
||||
</Flexbox>
|
||||
<Switch
|
||||
checked={proxyRequireAuth}
|
||||
disabled={!isEnableProxy}
|
||||
onChange={(checked) => {
|
||||
updateFormValue({ proxyRequireAuth: checked });
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
dependencies={['proxyRequireAuth', 'enableProxy']}
|
||||
label={t('proxy.username')}
|
||||
name="proxyUsername"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
message: t('proxy.validation.usernameRequired'),
|
||||
required: getFieldValue('proxyRequireAuth') && getFieldValue('enableProxy'),
|
||||
}),
|
||||
]}
|
||||
style={{
|
||||
display:
|
||||
form.getFieldValue('proxyRequireAuth') && form.getFieldValue('enableProxy')
|
||||
? 'block'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Input placeholder={t('proxy.username_placeholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
dependencies={['proxyRequireAuth', 'enableProxy']}
|
||||
label={t('proxy.password')}
|
||||
name="proxyPassword"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
message: t('proxy.validation.passwordRequired'),
|
||||
required: getFieldValue('proxyRequireAuth') && getFieldValue('enableProxy'),
|
||||
}),
|
||||
]}
|
||||
style={{
|
||||
display:
|
||||
form.getFieldValue('proxyRequireAuth') && form.getFieldValue('enableProxy')
|
||||
? 'block'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Input.Password placeholder={t('proxy.password_placeholder')} />
|
||||
</Form.Item>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
|
||||
{/* 连接测试 */}
|
||||
|
||||
<Block
|
||||
paddingBlock={16}
|
||||
paddingInline={24}
|
||||
style={{ borderRadius: 12 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox gap={24}>
|
||||
<Flexbox>
|
||||
<Text as={'h4'}>{t('proxy.connectionTest')}</Text>
|
||||
<Text type={'secondary'}>{t('proxy.testDescription')}</Text>
|
||||
</Flexbox>
|
||||
<Form.Item label={t('proxy.testUrl')}>
|
||||
<Flexbox gap={8}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
placeholder={t('proxy.testUrlPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={testUrl}
|
||||
/>
|
||||
<Button loading={isTesting} onClick={handleTest} type="default">
|
||||
{t('proxy.testButton')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{/* 测试结果显示 */}
|
||||
{!testResult ? null : testResult.success ? (
|
||||
<Alert
|
||||
closable
|
||||
title={
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
{t('proxy.testSuccessWithTime', { time: testResult.responseTime })}
|
||||
</Flexbox>
|
||||
}
|
||||
type={'success'}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
closable
|
||||
title={
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
{t('proxy.testFailed')}: {testResult.message}
|
||||
</Flexbox>
|
||||
}
|
||||
type={'error'}
|
||||
variant={'outlined'}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Form.Item>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
{/* 操作按钮 */}
|
||||
<Space>
|
||||
<Flexbox gap={24}>
|
||||
<Form
|
||||
collapsible={false}
|
||||
form={form}
|
||||
initialValues={proxySettings}
|
||||
items={[enableProxyGroup, basicSettingsGroup, authGroup, testGroup]}
|
||||
itemsType={'group'}
|
||||
onValuesChange={handleValuesChange}
|
||||
variant={'filled'}
|
||||
{...FORM_STYLE}
|
||||
/>
|
||||
<Flexbox align="end" justify="flex-end">
|
||||
{hasUnsavedChanges && (
|
||||
<span style={{ color: 'var(--ant-color-warning)', marginBottom: 8 }}>
|
||||
{t('proxy.unsavedChanges')}
|
||||
</span>
|
||||
)}
|
||||
<Flexbox gap={8} horizontal>
|
||||
<Button
|
||||
disabled={!hasUnsavedChanges}
|
||||
loading={isSaving}
|
||||
@@ -349,19 +259,12 @@ const ProxyForm = () => {
|
||||
>
|
||||
{t('proxy.saveButton')}
|
||||
</Button>
|
||||
|
||||
<Button disabled={!hasUnsavedChanges || isSaving} onClick={handleReset}>
|
||||
{t('proxy.resetButton')}
|
||||
</Button>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<Text style={{ marginLeft: 8 }} type="warning">
|
||||
{t('proxy.unsavedChanges')}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Form>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title={t('tab.proxy')} />
|
||||
<div style={{ maxWidth: '1024px', width: '100%' }}>
|
||||
<ProxyForm />
|
||||
</div>
|
||||
<ProxyForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ export const useMarkdown = (id: string): Partial<MarkdownProps> => {
|
||||
() =>
|
||||
({
|
||||
components: Object.fromEntries(
|
||||
// @ts-expect-error
|
||||
markdownElements.map((element) => {
|
||||
const Component = element.Component;
|
||||
return [element.tag, (props: any) => <Component {...props} id={id} />];
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { DEFAULT_INBOX_AVATAR } from '@lobechat/const';
|
||||
import { nanoid } from '@lobechat/utils';
|
||||
import { HIDE_TOOLBAR_COMMAND, type IEditor } from '@lobehub/editor';
|
||||
import { type ChatInputActionsProps } from '@lobehub/editor/react';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { Avatar, Block } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { BotIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
@@ -23,11 +24,14 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
}));
|
||||
|
||||
export const useAskCopilotItem = (editor: IEditor | undefined): ChatInputActionsProps['items'] => {
|
||||
const { t } = useTranslation('common');
|
||||
const addSelectionContext = useFileStore((s) => s.addChatContextSelection);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!editor) return [];
|
||||
|
||||
const label = t('cmdk.askLobeAI');
|
||||
|
||||
return [
|
||||
{
|
||||
children: (
|
||||
@@ -82,14 +86,14 @@ export const useAskCopilotItem = (editor: IEditor | undefined): ChatInputActions
|
||||
paddingInline={12}
|
||||
variant="borderless"
|
||||
>
|
||||
<BotIcon />
|
||||
<span>Ask Copilot</span>
|
||||
<Avatar avatar={DEFAULT_INBOX_AVATAR} shape="square" size={16} />
|
||||
<span>{label}</span>
|
||||
</Block>
|
||||
),
|
||||
key: 'ask-copilot',
|
||||
label: 'Ask Copilot',
|
||||
label,
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [addSelectionContext, editor]);
|
||||
}, [addSelectionContext, editor, t]);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Avatar, Dropdown, Text } from '@lobehub/ui';
|
||||
import { ArrowLeftIcon, BotMessageSquareIcon, MoreHorizontal } from 'lucide-react';
|
||||
import { ActionIcon, Avatar, DropdownMenu, Text } from '@lobehub/ui';
|
||||
import { ArrowLeftIcon, MoreHorizontal } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -49,18 +49,20 @@ const Header = memo(() => {
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<ToggleRightPanelButton icon={BotMessageSquareIcon} showActive={true} />
|
||||
{/* Three-dot menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
style: { minWidth: 200 },
|
||||
}}
|
||||
<DropdownMenu
|
||||
items={menuItems}
|
||||
nativeButton={false}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
popupProps={{
|
||||
style: {
|
||||
minWidth: 200,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ActionIcon icon={MoreHorizontal} size={DESKTOP_HEADER_ICON_SIZE} />
|
||||
</Dropdown>
|
||||
</DropdownMenu>
|
||||
<ToggleRightPanelButton hideWhenExpanded showActive={false} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flexbox, Icon } from '@lobehub/ui';
|
||||
import { App, Switch } from 'antd';
|
||||
import { type DropdownItem, Icon } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { cssVar, useResponsive } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { CopyPlus, Download, Link2, Trash2 } from 'lucide-react';
|
||||
@@ -75,25 +75,17 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
const menuItems = useMemo<DropdownItem[]>(() => {
|
||||
const items: DropdownItem[] = [
|
||||
...(showViewModeSwitch
|
||||
? [
|
||||
{
|
||||
checked: wideScreen,
|
||||
key: 'full-width',
|
||||
label: (
|
||||
<Flexbox align="center" horizontal justify="space-between">
|
||||
<span>{t('viewMode.fullWidth', { ns: 'chat' })}</span>
|
||||
<Switch
|
||||
checked={wideScreen}
|
||||
onChange={toggleWideScreen}
|
||||
onClick={(checked, event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Flexbox>
|
||||
),
|
||||
label: t('viewMode.fullWidth', { ns: 'chat' }),
|
||||
onCheckedChange: toggleWideScreen,
|
||||
|
||||
type: 'checkbox' as const,
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
@@ -140,38 +132,43 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
key: 'export',
|
||||
label: t('pageEditor.menu.export'),
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
key: 'page-info',
|
||||
label: (
|
||||
<div style={{ color: cssVar.colorTextTertiary, fontSize: 12, lineHeight: 1.6 }}>
|
||||
<div>
|
||||
{lastUpdatedTime
|
||||
? t('pageEditor.editedAt', {
|
||||
time: dayjs(lastUpdatedTime).format('MMMM D, YYYY [at] h:mm A'),
|
||||
})
|
||||
: ''}
|
||||
];
|
||||
|
||||
if (lastUpdatedTime) {
|
||||
items.push(
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
key: 'page-info',
|
||||
label: (
|
||||
<div style={{ color: cssVar.colorTextTertiary, fontSize: 12, lineHeight: 1.6 }}>
|
||||
<div>
|
||||
{lastUpdatedTime
|
||||
? t('pageEditor.editedAt', {
|
||||
time: dayjs(lastUpdatedTime).format('MMMM D, YYYY [at] h:mm A'),
|
||||
})
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
lastUpdatedTime,
|
||||
storeApi,
|
||||
t,
|
||||
message,
|
||||
modal,
|
||||
wideScreen,
|
||||
toggleWideScreen,
|
||||
showViewModeSwitch,
|
||||
handleDuplicate,
|
||||
handleExportMarkdown,
|
||||
],
|
||||
);
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
lastUpdatedTime,
|
||||
storeApi,
|
||||
t,
|
||||
message,
|
||||
modal,
|
||||
wideScreen,
|
||||
toggleWideScreen,
|
||||
showViewModeSwitch,
|
||||
handleDuplicate,
|
||||
handleExportMarkdown,
|
||||
]);
|
||||
|
||||
return { menuItems };
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { HotkeyEnum } from '@/types/hotkey';
|
||||
export const TOGGLE_BUTTON_ID = 'toggle_right_panel_button';
|
||||
|
||||
interface ToggleRightPanelButtonProps {
|
||||
hideWhenExpanded?: boolean;
|
||||
icon?: ActionIconProps['icon'];
|
||||
showActive?: boolean;
|
||||
size?: ActionIconProps['size'];
|
||||
@@ -22,7 +23,7 @@ interface ToggleRightPanelButtonProps {
|
||||
}
|
||||
|
||||
const ToggleRightPanelButton = memo<ToggleRightPanelButtonProps>(
|
||||
({ title, showActive, icon, size }) => {
|
||||
({ title, showActive, icon, hideWhenExpanded, size }) => {
|
||||
const [expand, togglePanel] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showRightPanel(s),
|
||||
s.toggleRightPanel,
|
||||
@@ -31,6 +32,7 @@ const ToggleRightPanelButton = memo<ToggleRightPanelButtonProps>(
|
||||
|
||||
const { t } = useTranslation(['chat', 'hotkey']);
|
||||
|
||||
if (hideWhenExpanded && expand) return null;
|
||||
return (
|
||||
<ActionIcon
|
||||
active={showActive ? expand : undefined}
|
||||
|
||||
40
tests/mocks/lru_map.ts
Normal file
40
tests/mocks/lru_map.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
class LRUMap<K, V> {
|
||||
private map = new Map<K, V>();
|
||||
private limit: number;
|
||||
|
||||
constructor(limit = 0) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
get(key: K) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
set(key: K, value: V) {
|
||||
if (!this.map.has(key) && this.limit > 0 && this.map.size >= this.limit) {
|
||||
const oldest = this.map.keys().next().value as K | undefined;
|
||||
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
|
||||
this.map.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key: K) {
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export { LRUMap };
|
||||
export default { LRUMap };
|
||||
@@ -56,6 +56,7 @@ export default defineConfig({
|
||||
'@/const': resolve(__dirname, './packages/const/src'),
|
||||
'@': resolve(__dirname, './src'),
|
||||
'~test-utils': resolve(__dirname, './tests/utils.tsx'),
|
||||
'lru_map': resolve(__dirname, './tests/mocks/lru_map'),
|
||||
/* eslint-enable */
|
||||
},
|
||||
coverage: {
|
||||
@@ -94,7 +95,14 @@ export default defineConfig({
|
||||
globals: true,
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['vitest-canvas-mock', '@lobehub/ui', '@lobehub/fluent-emoji'],
|
||||
inline: [
|
||||
'vitest-canvas-mock',
|
||||
'@lobehub/ui',
|
||||
'@lobehub/fluent-emoji',
|
||||
'@pierre/diffs',
|
||||
'@pierre/diffs/react',
|
||||
'lru_map',
|
||||
],
|
||||
},
|
||||
},
|
||||
setupFiles: join(__dirname, './tests/setup.ts'),
|
||||
|
||||
Reference in New Issue
Block a user