init push

This commit is contained in:
2026-05-21 19:52:45 +08:00
commit e3f75311ab
1280 changed files with 179173 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import DocEditor from '@/views/editor';
export default function EditorPage() {
return <DocEditor />;
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import Home from '@/views/home';
import { WelcomeFooter } from '@/components/footer';
import { ThemeProvider } from '@ctzhian/ui';
import { WelcomeHeader } from '@/components/header';
import { Stack, createTheme } from '@mui/material';
import { createComponentStyleOverrides } from '@/theme';
import { useStore } from '@/provider';
import { THEME_TO_PALETTE } from '@panda-wiki/themes/constants';
const HomePage = () => {
const { kbDetail } = useStore();
const [showSearch, setShowSearch] = useState(false);
useEffect(() => {
let ticking = false;
const checkVisibility = () => {
const elements = document.querySelectorAll('.banner-search-box');
if (elements.length > 0) {
// 判断是否还有任意一个搜索框处于可视区域内
// 顶部预留 64px 为头部占用区域
const hasVisibleBox = Array.from(elements).some(el => {
const rect = el.getBoundingClientRect();
return rect.bottom > 64 && rect.top < window.innerHeight;
});
setShowSearch(!hasVisibleBox);
} else {
setShowSearch(window.scrollY >= window.innerHeight);
}
};
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
checkVisibility();
ticking = false;
});
ticking = true;
}
};
checkVisibility();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const theme = useMemo(() => {
// @ts-ignore
const themeMode = kbDetail?.settings?.web_app_landing_theme?.name || 'blue';
return createTheme({
cssVariables: {
cssVarPrefix: 'welcome',
},
palette:
THEME_TO_PALETTE[themeMode]?.palette ||
THEME_TO_PALETTE['blue'].palette,
typography: {
fontFamily: 'var(--font-gilory), PingFang SC, sans-serif',
},
components: createComponentStyleOverrides(true),
});
// @ts-ignore
}, [kbDetail?.settings?.web_app_landing_theme?.name]);
return (
<ThemeProvider theme={theme}>
<Stack
justifyContent='space-between'
sx={{ minHeight: '100vh', bgcolor: 'background.default' }}
>
<WelcomeHeader showSearch={showSearch} />
<Stack sx={{ flex: 1 }}>
<Home />
</Stack>
<WelcomeFooter />
</Stack>
</ThemeProvider>
);
};
export default HomePage;

View File

@@ -0,0 +1,7 @@
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
const Layout = ({ children }: { children: React.ReactNode }) => {
return <WaterMarkProvider>{children}</WaterMarkProvider>;
};
export default Layout;

View File

@@ -0,0 +1,118 @@
'use client';
import { FooterSetting } from '@/assets/type';
import EmptyDocPlaceholder from '@/components/emptyDocPlaceholder';
import { FooterProvider } from '@/components/footer';
import Header from '@/components/header';
import { CONTENT_GAP } from '@/constant';
import { useSyncNavByDocId } from '@/hooks/useSyncNavByDocId';
import { useStore } from '@/provider';
import Catalog from '@/views/node/Catalog';
import CatalogH5 from '@/views/node/CatalogH5';
import NavBar from '@/views/node/NavBar';
import { Box, Stack } from '@mui/material';
import { useMemo } from 'react';
const PCLayout = ({ children }: { children: React.ReactNode }) => {
const { tree, kbDetail, catalogWidth = 260 } = useStore();
const docWidth = useMemo(
() => kbDetail?.settings?.theme_and_style?.doc_width || 'full',
[kbDetail],
);
return (
<Stack sx={{ height: '100vh', overflow: 'auto' }} id='scroll-container'>
<Header isDocPage={true} />
<NavBar docWidth={docWidth} catalogWidth={catalogWidth} />
{tree?.length === 0 ? (
<EmptyDocPlaceholder />
) : (
<Stack sx={{ flex: 1, px: 5, alignItems: 'center' }}>
<Stack
direction='row'
justifyContent='center'
alignItems='flex-start'
gap={`${CONTENT_GAP}px`}
sx={{
pt: '50px',
pb: 10,
flex: 1,
width: '100%',
}}
>
<Catalog />
{children}
</Stack>
</Stack>
)}
<FooterProvider isDocPage={true} />
</Stack>
);
};
const MobileLayout = ({
children,
footerSetting,
}: {
children?: React.ReactNode;
footerSetting?: FooterSetting | null;
}) => {
const { tree } = useStore();
return (
<Stack
sx={{
position: 'relative',
height: '100vh',
overflow: 'auto',
zIndex: 1,
}}
>
<Box sx={{ flex: 1 }}>
<Header />
<NavBar />
{tree?.length === 0 ? (
<EmptyDocPlaceholder mobile />
) : (
<>
<CatalogH5 />
{children}
</>
)}
</Box>
<Box
sx={{
mt: 5,
bgcolor: 'background.paper3',
...(footerSetting?.footer_style === 'complex' && {
borderTop: '1px solid',
borderColor: 'divider',
}),
}}
>
<FooterProvider />
</Box>
</Stack>
);
};
export default function NodeClientLayout({
children,
}: {
children: React.ReactNode;
}) {
const { mobile, kbDetail } = useStore();
const footerSetting = kbDetail?.settings?.footer_settings;
useSyncNavByDocId();
return (
<>
{mobile ? (
<MobileLayout footerSetting={footerSetting}>{children}</MobileLayout>
) : (
<PCLayout>{children}</PCLayout>
)}
</>
);
}

View File

@@ -0,0 +1,47 @@
import { getShareV1NodeDetail } from '@/request/ShareNode';
import type { V1ShareNodeDetailResp } from '@/request/types';
import { formatMeta } from '@/utils';
import Doc from '@/views/node';
import { ResolvingMetadata } from 'next';
export interface PageProps {
params: Promise<{ id: string }>;
}
const defaultNode = {
name: '无权访问',
meta: { summary: '无权访问' },
};
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata,
) {
const { id } = await params;
let node: { name?: string; meta?: { summary?: string } } = defaultNode;
try {
const res = await getShareV1NodeDetail({ id, format: 'json' });
node = (res as V1ShareNodeDetailResp) ?? defaultNode;
} catch {
// 使用默认 node
}
return await formatMeta(
{ title: node?.name, description: node?.meta?.summary },
parent,
);
}
const DocPage = async ({ params }: PageProps) => {
const { id = '' } = await params;
let error: unknown = null;
let node: V1ShareNodeDetailResp | null = null;
try {
const res = await getShareV1NodeDetail({ id, format: 'json' });
node = (res as V1ShareNodeDetailResp) ?? null;
} catch (err) {
error = err;
}
return <Doc node={node ?? undefined} error={error as Error} />;
};
export default DocPage;

View File

@@ -0,0 +1,5 @@
'use client';
import ErrorComponent from '@/components/error';
export default ErrorComponent;

View File

@@ -0,0 +1,47 @@
import StoreProvider from '@/provider';
import { getShareV1NodeList } from '@/request/ShareNode';
import { parsePathname } from '@/utils';
import { getServerPathname } from '@/utils/getServerHeader';
import {
convertToTree,
filterEmptyFolders,
parseNodeListResponse,
} from '@/utils/tree';
import NodeClientLayout from './NodeClientLayout';
export default async function Layout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [nodeListRes, pathname] = await Promise.all([
getShareV1NodeList(),
getServerPathname(),
]);
const { page, id } = parsePathname(pathname);
const nodeId = page === 'node' ? id : undefined;
const nodeListRaw = nodeListRes ?? [];
const { isGrouped, navList, navDataMap, defaultNavId } =
parseNodeListResponse(nodeListRaw, nodeId);
const nodeListForTree = isGrouped
? (navDataMap[defaultNavId || ''] ?? navDataMap[Object.keys(navDataMap)[0]])
: nodeListRaw;
const tree = filterEmptyFolders(
convertToTree((nodeListForTree || []) as any),
);
return (
<StoreProvider
nodeList={
(Array.isArray(nodeListRaw) && !isGrouped ? nodeListRaw : []) as any
}
tree={tree}
navList={navList}
selectedNavId={defaultNavId || (navList[0]?.id ?? '')}
navDataMap={navDataMap}
>
<NodeClientLayout>{children}</NodeClientLayout>
</StoreProvider>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { useStore } from '@/provider';
import { redirect } from 'next/navigation';
import React from 'react';
import { deepSearchFirstNode } from '@/utils';
import { useBasePath } from '@/hooks';
const NodePage = () => {
const basePath = useBasePath();
const { tree } = useStore();
const firstNode = deepSearchFirstNode(tree || []);
if (firstNode) {
return redirect(`${basePath}/node/${firstNode.id}`);
}
return <></>;
};
export default NodePage;

View File

@@ -0,0 +1,3 @@
import HomePage from '../home/page';
export default HomePage;

View File

@@ -0,0 +1,7 @@
import Login from '@/views/auth/login';
const LoginPage = async () => {
return <Login />;
};
export default LoginPage;

View File

@@ -0,0 +1,38 @@
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import parse, { DOMNode, domToReact } from 'html-react-parser';
import Script from 'next/script';
const Layout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
const kbDetail = await getShareV1AppWebInfo();
const options = {
replace(domNode: DOMNode) {
if (domNode.type === 'script') {
if (!domNode.children) return <Script {...domNode.attribs} />;
return (
<Script {...domNode.attribs}>
{domToReact(domNode.children as any, options)}
</Script>
);
}
},
};
return (
<>
{kbDetail?.settings?.head_code ? (
<>{parse(kbDetail.settings.head_code, options)}</>
) : null}
{children}
{kbDetail?.settings?.body_code && (
<>{parse(kbDetail.settings.body_code, options)}</>
)}
</>
);
};
export default Layout;

View File

@@ -0,0 +1,48 @@
import notFound from '@/assets/images/404.png';
import { FooterProvider } from '@/components/footer';
import { Box, Stack } from '@mui/material';
import Image from 'next/image';
export default function NotFound() {
return (
<Box
sx={{
position: 'relative',
pt: 28,
height: '100vh',
}}
>
<Stack
sx={{
maxWidth: 1200,
overflow: 'auto',
pb: 6,
mx: 'auto',
}}
justifyContent='center'
alignItems='center'
>
<Image src={notFound} alt='404' width={380} height={200} />
<Stack
gap={3}
alignItems='center'
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
>
</Stack>
</Stack>
<Box
sx={{
height: 40,
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<FooterProvider showBrand={false} />
</Box>
</Box>
);
}

27
web/app/src/app/error.tsx Normal file
View File

@@ -0,0 +1,27 @@
'use client';
import { FooterProvider } from '@/components/footer';
import { Stack } from '@mui/material';
import ErrorComponent from '@/components/error';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<Stack
justifyContent='space-between'
alignItems='center'
sx={{
height: '100vh',
}}
>
<Stack flex={1} justifyContent='center' alignItems='center'>
<ErrorComponent error={error} reset={reset} />
</Stack>
<FooterProvider showBrand={false} />
</Stack>
);
}

BIN
web/app/src/app/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,19 @@
import StoreProvider from '@/provider';
import { lightTheme } from '@/theme';
import { ThemeProvider } from '@ctzhian/ui';
import React from 'react';
const Layout = async ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<ThemeProvider theme={lightTheme}>
<StoreProvider themeMode={'light'}>{children}</StoreProvider>
</ThemeProvider>
);
};
export default Layout;

View File

@@ -0,0 +1,17 @@
import Feedback from '@/views/feedback';
import { Box } from '@mui/material';
const FeedbackPage = () => {
return (
<Box
sx={{
width: '100vw',
height: '100vh',
}}
>
<Feedback />
</Box>
);
};
export default FeedbackPage;

View File

@@ -0,0 +1,78 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
import ErrorPng from '@/assets/images/500.png';
import Footer from '@/components/footer';
import { lightTheme } from '@/theme';
import { Box, Stack } from '@mui/material';
import { ThemeProvider } from '@ctzhian/ui';
import Image from 'next/image';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 只在生产环境下上报错误到 Sentry
if (process.env.NODE_ENV === 'production') {
Sentry.captureException(error);
}
}, [error]);
return (
<html lang='en'>
<body>
<ThemeProvider theme={lightTheme}>
<Box
sx={{
position: 'relative',
pt: 28,
height: '100vh',
}}
>
<Stack
sx={{
maxWidth: 1200,
overflow: 'auto',
pb: 6,
mx: 'auto',
}}
justifyContent='center'
alignItems='center'
>
<Image
src={ErrorPng}
unoptimized
alt='404'
width={380}
height={200}
/>
<Stack
gap={3}
alignItems='center'
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
>
{error.digest}
</Stack>
</Stack>
<Box
sx={{
height: 40,
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<Footer showBrand={false} />
</Box>
</Box>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,87 @@
@import './markdown.css';
@import '@ctzhian/tiptap/dist/index.css';
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body {
font-family:
var(--font-gilory), 'Roboto', 'Helvetica', 'Arial', sans-serif !important;
}
a {
color: inherit;
text-decoration: none;
}
@keyframes loadingRotate {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
[class^='ellipsis-'] {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis-1 {
-webkit-line-clamp: 1;
}
.ellipsis-2 {
-webkit-line-clamp: 2;
}
.ellipsis-3 {
-webkit-line-clamp: 3;
}
.ellipsis-4 {
-webkit-line-clamp: 4;
}
::-webkit-scrollbar {
width: 4px;
/* 纵向滚动条*/
height: 0;
/* 横向滚动条隐藏 */
border-radius: 10px;
}
/*定义滚动条轨道 内阴影*/
::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #fff;
border-radius: 10px;
}
/*定义滑块 内阴影*/
::-webkit-scrollbar-thumb {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #ccc;
border-radius: 10px;
}
/*定义滚动条轨道 内阴影*/
.dark ::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #363636;
border-radius: 10px;
}
/*定义滑块 内阴影*/
.dark ::-webkit-scrollbar-thumb {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #9b9b9b;
border-radius: 10px;
}

View File

@@ -0,0 +1,3 @@
import H5Chat from '@/views/h5Chat';
export default H5Chat;

134
web/app/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,134 @@
import ErrorComponent from '@/components/error';
import StoreProvider from '@/provider';
import { ThemeStoreProvider } from '@/provider/themeStore';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
import Script from 'next/script';
import { Box } from '@mui/material';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
import type { Metadata, Viewport } from 'next';
import localFont from 'next/font/local';
import { headers, cookies } from 'next/headers';
import { getSelectorsByUserAgent } from 'react-device-detect';
import { getBasePath, getImagePath } from '@/utils';
import './globals.css';
const gilory = localFont({
variable: '--font-gilory',
src: [
{
path: '../assets/fonts/gilroy-bold-700.otf',
weight: '700',
},
{
path: '../assets/fonts/gilroy-medium-500.otf',
weight: '400',
},
{
path: '../assets/fonts/gilroy-regular-400.otf',
weight: '300',
},
],
});
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export async function generateMetadata(): Promise<Metadata> {
const kbDetail: any = await getShareV1AppWebInfo();
const basePath = getBasePath(kbDetail?.base_url || '');
const icon = getImagePath(kbDetail?.settings?.icon || '', basePath);
return {
metadataBase: new URL(process.env.TARGET || ''),
title: kbDetail?.settings?.title || 'Panda-Wiki',
description: kbDetail?.settings?.desc || '',
keywords: kbDetail?.settings?.keyword || '',
icons: {
icon: icon || `${basePath}/favicon.png`,
},
openGraph: {
title: kbDetail?.settings?.title || 'Panda-Wiki',
description: kbDetail?.settings?.desc || '',
images: icon ? [icon] : [],
},
};
}
const Layout = async ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const headersList = await headers();
const userAgent = headersList.get('user-agent');
const cookieStore = await cookies();
const themeMode = (cookieStore.get('theme_mode')?.value || 'light') as
| 'light'
| 'dark';
let error: any = null;
const [kbDetailResolve, authInfoResolve] = await Promise.allSettled([
getShareV1AppWebInfo(),
getShareProV1AuthInfo({}),
]);
const authInfo: any =
authInfoResolve.status === 'fulfilled' ? authInfoResolve.value : undefined;
const kbDetail: any =
kbDetailResolve.status === 'fulfilled' ? kbDetailResolve.value : undefined;
if (
authInfoResolve.status === 'rejected' &&
authInfoResolve.reason.code === 403
) {
error = authInfoResolve.reason;
}
const { isMobile } = getSelectorsByUserAgent(userAgent || '') || {
isMobile: false,
};
const basePath = getBasePath(kbDetail?.base_url || '');
return (
<html lang='en'>
<Script
id='base-path'
dangerouslySetInnerHTML={{
__html: `window._BASE_PATH_ = '${basePath}';`,
}}
/>
<body
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : 'light'}`}
>
<AppRouterCacheProvider>
<ThemeStoreProvider themeMode={themeMode}>
<StoreProvider
kbDetail={kbDetail}
themeMode={themeMode || 'light'}
mobile={isMobile}
authInfo={authInfo}
>
<Box
sx={{
bgcolor: 'background.paper',
height: error ? '100vh' : 'auto',
}}
id='app-theme-root'
>
{error ? <ErrorComponent error={error} /> : children}
</Box>
</StoreProvider>
</ThemeStoreProvider>
</AppRouterCacheProvider>
</body>
</html>
);
};
export default Layout;

1242
web/app/src/app/markdown.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import notFound from '@/assets/images/404.png';
import { FooterProvider } from '@/components/footer';
import { Box, Stack } from '@mui/material';
import Image from 'next/image';
export default function NotFound() {
return (
<Box
sx={{
position: 'relative',
pt: 28,
height: '100vh',
}}
>
<Stack
sx={{
maxWidth: 1200,
overflow: 'auto',
pb: 6,
mx: 'auto',
}}
justifyContent='center'
alignItems='center'
>
<Image src={notFound} alt='404' width={380} height={200} />
<Stack
gap={3}
alignItems='center'
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
>
</Stack>
</Stack>
<Box
sx={{
height: 40,
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<FooterProvider showBrand={false} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import StoreProvider from '@/provider';
import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
import { darkThemeWidget, lightThemeWidget } from '@/theme';
import { ThemeProvider } from '@ctzhian/ui';
import React from 'react';
const Layout = async ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const widgetDetail: any = await getShareV1AppWidgetInfo();
const themeMode = widgetDetail?.settings?.widget_bot_settings?.theme_mode;
let selectedTheme = lightThemeWidget;
if (themeMode === 'dark') {
selectedTheme = darkThemeWidget;
}
return (
<ThemeProvider theme={selectedTheme}>
<StoreProvider widget={widgetDetail}>{children}</StoreProvider>
</ThemeProvider>
);
};
export default Layout;

View File

@@ -0,0 +1,3 @@
import Widget from '@/views/widget';
export default Widget;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,206 @@
import {
ConstsCopySetting,
ConstsWatermarkSetting,
DomainDisclaimerSettings,
DomainConversationSetting,
DomainWebAppLandingConfig,
} from '@/request/types';
export interface NavBtn {
id: string;
url: string;
variant: 'contained' | 'outlined';
showIcon: boolean;
icon: string;
text: string;
target: '_blank' | '_self';
}
export interface Heading {
id: string;
title: string;
heading: number;
}
export interface FooterSetting {
footer_style: 'simple' | 'complex';
corp_name: string;
icp: string;
brand_name: string;
brand_desc: string;
brand_logo: string;
brand_groups: BrandGroup[];
}
export interface BrandGroup {
name: string;
links: {
name: string;
url: string;
}[];
}
export interface AuthSetting {
enabled: boolean;
password?: string;
}
export interface CatalogSetting {
catalog_visible: 1 | 2;
catalog_folder: 1 | 2;
catalog_width: number;
}
export interface ThemeAndStyleSetting {
bg_image: string;
doc_width: string;
}
export interface KBDetail {
name: string;
base_url?: string;
settings: {
conversation_setting: DomainConversationSetting;
title: string;
btns: NavBtn[];
icon: string;
welcome_str: string;
search_placeholder: string;
recommend_questions: string[];
recommend_node_ids: string[];
desc: string;
keyword: string;
head_code: string;
body_code: string;
theme_mode?: 'light' | 'dark';
simple_auth?: AuthSetting | null;
footer_settings?: FooterSetting | null;
catalog_settings?: CatalogSetting | null;
theme_and_style?: ThemeAndStyleSetting | null;
watermark_content?: string;
watermark_setting?: ConstsWatermarkSetting;
copy_setting?: ConstsCopySetting;
disclaimer_settings?: DomainDisclaimerSettings;
web_app_custom_style: {
allow_theme_switching?: boolean;
header_search_placeholder?: string;
show_brand_info?: boolean;
social_media_accounts?: DomainSocialMediaAccount[];
footer_show_intro?: boolean;
};
contribute_settings?: {
is_enable: boolean;
};
web_app_landing_configs: DomainWebAppLandingConfig[];
};
}
export interface DomainSocialMediaAccount {
channel?: string;
icon?: string;
link?: string;
text?: string;
phone?: string;
}
export type WidgetInfo = {
recommend_nodes: RecommendNode[];
settings: {
title: string;
icon: string;
welcome_str: string;
search_placeholder: string;
recommend_questions: string[];
widget_bot_settings: {
btn_logo?: string;
btn_text?: string;
btn_style?: string;
btn_id?: string;
btn_position?: string;
modal_position?: string;
is_open?: boolean;
recommend_node_ids?: string[];
recommend_questions?: string[];
theme_mode?: string;
search_mode?: string;
placeholder?: string;
disclaimer?: string;
copyright_hide_enabled?: boolean;
copyright_info?: string;
};
};
};
export type RecommendNode = {
id: string;
name: string;
type: 1 | 2;
emoji: string;
parent_id: string;
summary: string;
position: number;
recommend_nodes?: RecommendNode[];
};
export interface NodeDetail {
id: string;
kb_id: string;
name: string;
content: string;
created_at: string;
updated_at: string;
type: 1 | 2;
creator_account: string;
editor_account: string;
meta: {
doc_width: string;
summary: string;
emoji?: string;
};
}
export interface NodeListItem {
id: string;
name: string;
type: 1 | 2;
emoji: string;
position: number;
parent_id: string;
summary: string;
created_at: string;
updated_at: string;
status: 1 | 2; // 1 草稿 2 发布
}
export interface ChunkResultItem {
node_id: string;
name: string;
summary: string;
}
export interface ITreeItem {
id: string;
name: string;
level: number;
order?: number;
emoji?: string;
defaultExpand?: boolean;
expanded?: boolean;
parentId?: string | null;
summary?: string;
children?: ITreeItem[];
type: 1 | 2;
isEditting?: boolean;
canHaveChildren?: boolean;
updated_at?: string;
status?: 1 | 2;
}
export interface ConversationItem {
q: string;
a: string;
score: number;
update_time: string;
message_id: string;
source: 'history' | 'chat';
chunk_result: ChunkResultItem[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,436 @@
'use client';
import Logo from '@/assets/images/logo.png';
import noDocImage from '@/assets/images/no-doc.png';
import { useBasePath } from '@/hooks';
import { useStore } from '@/provider';
import { postShareV1ChatSearch } from '@/request/ShareChatSearch';
import { DomainNodeContentChunkSSE } from '@/request/types';
import { getImagePath } from '@/utils/getImagePath';
import { message } from '@ctzhian/ui';
import {
alpha,
Box,
CircularProgress,
IconButton,
InputAdornment,
Skeleton,
Stack,
styled,
TextField,
Typography,
} from '@mui/material';
import {
IconFasong,
IconJinsousuo,
IconMianbaoxie,
IconWenjian,
} from '@panda-wiki/icons';
import Image from 'next/image';
import React, { useState } from 'react';
const StyledSearchResultItem = styled(Stack)(({ theme }) => ({
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
borderBottom: '1px dashed',
borderColor: alpha(theme.palette.text.primary, 0.1),
},
'&::after': {
content: '""',
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
borderBottom: '1px dashed',
borderColor: alpha(theme.palette.text.primary, 0.1),
},
padding: theme.spacing(2),
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
'&:hover': {
backgroundColor: alpha(theme.palette.text.primary, 0.02),
'.hover-primary': {
color: 'primary.main',
},
},
}));
const SearchDocSkeleton = () => {
return (
<StyledSearchResultItem>
<Stack gap={1}>
<Skeleton variant='rounded' height={16} width={200} />
<Skeleton variant='rounded' height={22} width={400} />
<Skeleton variant='rounded' height={16} width={500} />
</Stack>
</StyledSearchResultItem>
);
};
interface SearchDocContentProps {
inputRef: React.RefObject<HTMLInputElement | null>;
placeholder: string;
}
const SearchDocContent: React.FC<SearchDocContentProps> = ({
inputRef,
placeholder,
}) => {
const { kbDetail } = useStore();
const basePath = useBasePath();
// 模糊搜索相关状态
const [fuzzySuggestions, setFuzzySuggestions] = useState<string[]>([]);
const [showFuzzySuggestions, setShowFuzzySuggestions] = useState(false);
const [input, setInput] = useState('');
const [hasSearch, setHasSearch] = useState(false);
// 搜索结果相关状态
const [searchResults, setSearchResults] = useState<
DomainNodeContentChunkSSE[]
>([]);
const [isSearching, setIsSearching] = useState(false);
// 处理输入变化,显示模糊搜索建议
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInput(value);
// if (value.trim().length > 0) {
// // 改进的模糊搜索逻辑
// const filtered = mockFuzzySuggestions
// .filter(suggestion => {
// const lowerSuggestion = suggestion.toLowerCase();
// const lowerValue = value.toLowerCase();
// // 支持前缀匹配和包含匹配
// return (
// lowerSuggestion.startsWith(lowerValue) ||
// lowerSuggestion.includes(lowerValue)
// );
// })
// .slice(0, 5); // 限制显示数量
// setFuzzySuggestions(filtered);
// setShowFuzzySuggestions(true);
// } else {
// setShowFuzzySuggestions(false);
// setFuzzySuggestions([]);
// }
};
// 选择模糊搜索建议
const handleFuzzySuggestionClick = (suggestion: string) => {
setInput(suggestion);
setShowFuzzySuggestions(false);
setFuzzySuggestions([]);
};
// 执行搜索
const handleSearch = async () => {
if (isSearching) return;
if (!input.trim()) return;
setIsSearching(true);
setSearchResults([]);
setShowFuzzySuggestions(false);
setFuzzySuggestions([]);
let token = '';
const Cap = (await import(`@cap.js/widget`)).default;
const cap = new Cap({
apiEndpoint: `${basePath}/share/v1/captcha/`,
});
try {
const solution = await cap.solve();
token = solution.token;
} catch (error) {
message.error('验证失败');
setIsSearching(false);
return;
}
postShareV1ChatSearch({ message: input, captcha_token: token })
.then(res => {
setSearchResults(res.node_result || []);
setHasSearch(true);
})
.finally(() => {
setIsSearching(false);
});
};
// 处理搜索结果点击
const handleSearchResultClick = (result: DomainNodeContentChunkSSE) => {
window.open(`${basePath}/node/${result.node_id}`, '_blank');
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSearch();
}
};
// 高亮显示匹配的文本
const highlightMatch = (text: string, query: string) => {
if (!query.trim()) return text;
// 转义特殊字符,避免正则表达式错误
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) => {
// 检查是否匹配(不区分大小写)
if (part.toLowerCase() === query.toLowerCase()) {
return (
<Box
component='span'
key={index}
sx={{
color: 'primary.main',
}}
>
{part}
</Box>
);
}
return part;
});
};
return (
<Box>
<Stack
direction='row'
alignItems='center'
justifyContent='center'
gap={2}
sx={{ mb: 3, mt: 1 }}
>
<Image
src={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
alt='logo'
width={46}
height={46}
unoptimized
style={{
objectFit: 'contain',
}}
/>
<Typography
variant='h6'
sx={{ fontSize: 32, color: 'text.primary', fontWeight: 700 }}
>
{kbDetail?.settings?.title}
</Typography>
</Stack>
{/* 搜索输入框 */}
<TextField
ref={inputRef}
value={input}
placeholder={placeholder}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
fullWidth
autoFocus
sx={theme => ({
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
borderRadius: 2,
'& .MuiInputBase-root': {
fontSize: 16,
backgroundColor: theme.palette.background.default,
'& fieldset': {
borderColor: alpha(theme.palette.text.primary, 0.1),
},
'&:hover fieldset': {
borderColor: 'primary.main',
},
'&.Mui-focused fieldset': {
borderColor: `${theme.palette.primary.main} !important`,
borderWidth: 1,
},
},
'& .MuiInputBase-input': {
py: 1.5,
},
})}
slotProps={{
input: {
startAdornment: (
<InputAdornment position='start'>
<IconJinsousuo sx={{ fontSize: 20, color: 'text.secondary' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position='end'>
<IconButton
size='small'
onClick={handleSearch}
disabled={!input.trim() || isSearching}
sx={{
color: 'primary.main',
'&:hover': { bgcolor: 'primary.lighter' },
'&.Mui-disabled': { color: 'action.disabled' },
}}
>
{isSearching ? (
<CircularProgress size={20} />
) : (
<IconFasong
sx={{
fontSize: 22,
}}
/>
)}
</IconButton>
</InputAdornment>
),
},
}}
/>
{/* 模糊搜索建议列表 */}
{showFuzzySuggestions && fuzzySuggestions.length > 0 && (
<Stack
sx={{
mt: 1,
position: 'relative',
zIndex: 1000,
}}
gap={0.5}
>
{fuzzySuggestions.map((suggestion, index) => (
<Box
key={index}
onClick={() => handleFuzzySuggestionClick(suggestion)}
sx={{
py: 1,
px: 2,
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s',
bgcolor: 'transparent',
color: 'text.primary',
'&:hover': {
bgcolor: 'action.hover',
},
display: 'flex',
alignItems: 'center',
width: 'auto',
fontSize: 14,
fontWeight: 400,
}}
>
{highlightMatch(suggestion, input)}
</Box>
))}
</Stack>
)}
{/* 搜索结果列表 */}
{searchResults.length > 0 && (
<Box sx={{ mt: 2 }}>
{/* 搜索结果统计 */}
<Typography
variant='body2'
sx={{
color: 'text.tertiary',
mb: 2,
fontSize: 14,
}}
>
{searchResults.length}
</Typography>
{/* 搜索结果列表 */}
<Stack sx={{ overflow: 'auto', maxHeight: 'calc(100vh - 334px)' }}>
{searchResults.map((result, index) => (
<StyledSearchResultItem
direction='row'
justifyContent='space-between'
alignItems='center'
key={result.node_id}
gap={2}
onClick={() => handleSearchResultClick(result)}
>
<Stack sx={{ flex: 1, width: 0 }} gap={0.5}>
{/* 路径 */}
<Typography
variant='caption'
sx={{
color: 'text.tertiary',
fontSize: 12,
display: 'block',
}}
>
{(result.node_path_names || []).length > 0
? result.node_path_names?.join(' > ')
: result.name}
</Typography>
{/* 标题和图标 */}
<Typography
variant='h6'
className='hover-primary'
sx={{
gap: 0.5,
display: 'flex',
alignItems: 'center',
fontSize: 14,
fontWeight: 600,
color: 'text.primary',
flex: 1,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{result.emoji || <IconWenjian />} {result.name}
</Typography>
{/* 描述 */}
<Typography
variant='body2'
sx={{
color: 'text.tertiary',
fontSize: 12,
lineHeight: 1.5,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{result.summary || '暂无摘要'}
</Typography>
</Stack>
<IconMianbaoxie sx={{ fontSize: 12 }} />
</StyledSearchResultItem>
))}
</Stack>
</Box>
)}
{searchResults.length === 0 && !isSearching && hasSearch && (
<Box sx={{ my: 5, textAlign: 'center' }}>
<Image src={noDocImage} alt='暂无结果' width={250} />
<Typography variant='body2' sx={{ color: 'text.tertiary' }}>
</Typography>
</Box>
)}
{/* 搜索中状态 */}
{isSearching && (
<Stack sx={{ mt: 2 }}>
{[...Array(3)].map((_, index) => (
<SearchDocSkeleton key={index} />
))}
</Stack>
)}
</Box>
);
};
export default SearchDocContent;

View File

@@ -0,0 +1,344 @@
'use client';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
IconButton,
Stack,
TextField,
styled,
alpha,
} from '@mui/material';
// 布局容器组件
export const StyledMainContainer = styled(Box)(() => ({
flex: 1,
}));
export const StyledConversationContainer = styled(Stack)(() => ({
maxHeight: 'calc(100vh - 332px)',
overflow: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'&::-webkit-scrollbar': {
display: 'none',
},
}));
export const StyledConversationItem = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
// 聊天气泡相关组件
export const StyledUserBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-end',
maxWidth: '75%',
padding: theme.spacing(1, 2),
borderRadius: '10px 10px 0px 10px',
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
fontSize: 14,
wordBreak: 'break-word',
}));
export const StyledAiBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-start',
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: theme.spacing(3),
}));
export const StyledAiBubbleContent = styled(Box)(() => ({
wordBreak: 'break-word',
}));
// 对话相关组件
export const StyledAccordion = styled(Accordion)(() => ({
padding: 0,
border: 'none',
'&:before': {
content: '""',
height: 0,
},
background: 'transparent',
backgroundImage: 'none',
}));
export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
userSelect: 'text',
borderRadius: '10px',
backgroundColor: theme.palette.background.paper3,
border: '1px solid',
borderColor: theme.palette.divider,
}));
export const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: theme.spacing(2),
borderTop: 'none',
}));
export const StyledQuestionText = styled(Box)(() => ({
fontWeight: '700',
fontSize: 16,
lineHeight: '24px',
wordBreak: 'break-all',
}));
// 搜索结果相关组件
export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({
backgroundImage: 'none',
background: 'transparent',
border: 'none',
padding: 0,
}));
export const StyledChunkAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledChunkAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
}),
);
export const StyledChunkItem = styled(Box)(({ theme }) => ({
cursor: 'pointer',
'&:hover': {
'.hover-primary': {
color: theme.palette.primary.main,
},
},
}));
// 思考过程相关组件
export const StyledThinkingAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: 'transparent',
border: 'none',
padding: 0,
paddingBottom: theme.spacing(2),
'&:before': {
content: '""',
height: 0,
},
}));
export const StyledThinkingAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledThinkingAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
'.markdown-body': {
opacity: 0.75,
fontSize: 12,
},
}),
);
// 操作区域组件
export const StyledActionStack = styled(Stack)(({ theme }) => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.35),
}));
// 输入区域组件
export const StyledInputContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
export const StyledInputWrapper = styled(Stack)(({ theme }) => ({
paddingLeft: theme.spacing(1.5),
paddingRight: theme.spacing(1.5),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
borderRadius: '10px',
border: '1px solid',
borderColor: alpha(theme.palette.text.primary, 0.1),
display: 'flex',
alignItems: 'flex-end',
gap: theme.spacing(2),
backgroundColor: theme.palette.background.default,
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
transition: 'border-color 0.2s ease-in-out',
'&:hover': {
borderColor: theme.palette.primary.main,
},
'&:focus-within': {
borderColor: theme.palette.primary.main,
},
}));
// 图片预览组件
export const StyledImagePreviewStack = styled(Stack)(() => ({
width: '100%',
zIndex: 1,
}));
export const StyledImagePreviewItem = styled(Box)(({ theme }) => ({
position: 'relative',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid',
borderColor: theme.palette.divider,
}));
export const StyledImageRemoveButton = styled(IconButton)(({ theme }) => ({
position: 'absolute',
top: 2,
right: 2,
width: 16,
height: 16,
backgroundColor: theme.palette.background.paper,
border: '1px solid',
borderColor: theme.palette.divider,
transition: 'opacity 0.2s',
'&:hover': {
backgroundColor: theme.palette.background.paper,
},
}));
// 输入框组件
export const StyledTextField = styled(TextField)(({ theme }) => ({
backgroundColor: theme.palette.background.default,
'.MuiInputBase-root': {
padding: 0,
overflow: 'hidden',
height: '52px !important',
},
textarea: {
borderRadius: 0,
'&::-webkit-scrollbar': {
display: 'none',
},
scrollbarWidth: 'none',
msOverflowStyle: 'none',
padding: '2px',
},
fieldset: {
border: 'none',
},
}));
// 操作按钮组件
export const StyledActionButtonStack = styled(Stack)(() => ({
width: '100%',
}));
// 搜索建议组件
export const StyledFuzzySuggestionsStack = styled(Stack)(({ theme }) => ({
marginTop: theme.spacing(1),
position: 'relative',
zIndex: 1000,
}));
export const StyledFuzzySuggestionItem = styled(Box)(({ theme }) => ({
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: 'transparent',
color: theme.palette.text.primary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
display: 'flex',
alignItems: 'center',
width: 'auto',
fontSize: 14,
fontWeight: 400,
}));
// 热门搜索组件
export const StyledHotSearchStack = styled(Stack)(({ theme }) => ({
marginTop: theme.spacing(2),
}));
export const StyledHotSearchItem = styled(Box)(({ theme }) => ({
paddingTop: theme.spacing(0.75),
paddingBottom: theme.spacing(0.75),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
marginBottom: theme.spacing(1),
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: alpha(theme.palette.text.primary, 0.02),
border: `1px solid ${alpha(theme.palette.text.primary, 0.01)}`,
color: alpha(theme.palette.text.primary, 0.75),
'&:hover': {
color: theme.palette.primary.main,
},
alignSelf: 'flex-start',
display: 'inline-flex',
alignItems: 'center',
width: 'auto',
}));
// 热门搜索容器
export const StyledHotSearchContainer = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(2),
}));
// 热门搜索列
export const StyledHotSearchColumn = styled(Box)(({ theme }) => ({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
paddingLeft: theme.spacing(2),
borderLeft: `1px solid ${alpha(theme.palette.text.primary, 0.06)}`,
}));
// 热门搜索列项目
export const StyledHotSearchColumnItem = styled(Box)(({ theme }) => ({
paddingRight: theme.spacing(2),
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: 'transparent',
color: theme.palette.text.secondary,
fontSize: 12,
fontWeight: 400,
display: 'flex',
alignItems: 'center',
'&:hover': {
color: theme.palette.primary.main,
},
}));

View File

@@ -0,0 +1,15 @@
// 常量定义
export const MAX_IMAGES = 9;
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
export const CONVERSATION_MAX_HEIGHT = 'calc(100vh - 334px)';
export const FUZZY_SUGGESTIONS_LIMIT = 5;
// 回答状态
export const AnswerStatus = {
1: '正在搜索结果...',
2: '思考中...',
3: '正在回答',
4: '',
} as const;
export type AnswerStatusType = keyof typeof AnswerStatus;

View File

@@ -0,0 +1,282 @@
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { IconZhinengwenda, IconJinsousuo } from '@panda-wiki/icons';
import { useSearchParams } from 'next/navigation';
import {
Box,
Button,
Typography,
Modal,
Stack,
lighten,
alpha,
styled,
Tabs,
Tab,
} from '@mui/material';
import AiQaContent from './AiQaContent';
import SearchDocContent from './SearchDocContent';
import { useStore } from '@/provider';
interface SearchSuggestion {
id: string;
title: string;
description?: string;
type?: 'recent' | 'suggestion' | 'trending';
}
interface QaModalProps {
placeholder?: string;
initialValue?: string;
onSearch?: (value?: string, type?: 'search' | 'chat') => void;
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
defaultSuggestions?: SearchSuggestion[];
}
const StyledTabs = styled(Tabs)(({ theme }) => ({
minHeight: 'auto',
position: 'relative',
borderRadius: '10px',
padding: theme.spacing(0.5),
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
'& .MuiTabs-indicator': {
height: '100%',
borderRadius: '8px',
backgroundColor: theme.palette.primary.main,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 0,
},
'& .MuiTabs-flexContainer': {
gap: theme.spacing(0.5),
position: 'relative',
zIndex: 1,
},
}));
// 样式化的 Tab 组件 - 白色背景,圆角,深灰色文字
const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 'auto',
padding: theme.spacing(0.75, 2),
borderRadius: '6px',
backgroundColor: 'transparent',
fontSize: 12,
fontWeight: 400,
textTransform: 'none',
transition: 'color 0.3s ease-in-out',
position: 'relative',
zIndex: 1,
lineHeight: 1,
'&:hover': {
color: theme.palette.text.primary,
},
'&.Mui-selected': {
color: theme.palette.primary.contrastText,
fontWeight: 500,
},
}));
const QaModal: React.FC<QaModalProps> = () => {
const { qaModalOpen, setQaModalOpen, kbDetail, mobile } = useStore();
const [searchMode, setSearchMode] = useState<'chat' | 'search'>('chat');
const inputRef = useRef<HTMLInputElement>(null);
const aiQaInputRef = useRef<HTMLInputElement>(null);
const searchParams = useSearchParams();
const onClose = () => {
setQaModalOpen?.(false);
};
const placeholder = useMemo(() => {
return (
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder ||
'搜索...'
);
}, [kbDetail]);
const hotSearch = useMemo(() => {
const bannerConfig = kbDetail?.settings?.web_app_landing_configs?.find(
item => item.type === 'banner',
);
return bannerConfig?.banner_config?.hot_search || [];
}, [kbDetail]);
// modal打开时自动聚焦
useEffect(() => {
if (qaModalOpen) {
setTimeout(() => {
if (searchMode === 'chat') {
aiQaInputRef.current?.querySelector('textarea')?.focus();
} else {
inputRef.current?.querySelector('input')?.focus();
}
}, 100);
}
}, [qaModalOpen, searchMode]);
useEffect(() => {
if (!qaModalOpen) {
setTimeout(() => {
setSearchMode('chat');
}, 300);
}
}, [qaModalOpen]);
useEffect(() => {
const cid = searchParams.get('cid');
const ask = searchParams.get('ask');
if (cid || ask) {
setQaModalOpen?.(true);
}
}, []);
return (
<Modal
open={qaModalOpen as boolean}
onClose={onClose}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
p: 2,
}}
>
<Box
sx={theme => ({
display: 'flex',
flexDirection: 'column',
flex: 1,
maxWidth: 800,
maxHeight: '100%',
backgroundColor: lighten(theme.palette.background.default, 0.05),
borderRadius: '10px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
overflow: 'hidden',
outline: 'none',
pb: 2,
})}
onClick={e => e.stopPropagation()}
>
{/* 顶部标签栏 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
pt: 2,
pb: 2.5,
}}
>
<StyledTabs
value={searchMode}
onChange={(_, value) => {
setSearchMode(value as 'chat' | 'search');
}}
variant='scrollable'
scrollButtons={false}
>
<StyledTab
label={
<Stack direction='row' gap={0.5} alignItems='center'>
<IconZhinengwenda sx={{ fontSize: 16 }} />
{!mobile && <span></span>}
</Stack>
}
value='chat'
/>
<StyledTab
label={
<Stack direction='row' gap={0.5} alignItems='center'>
<IconJinsousuo sx={{ fontSize: 16 }} />
{!mobile && <span></span>}
</Stack>
}
value='search'
/>
</StyledTabs>
{/* Esc按钮 */}
{!mobile && (
<Button
variant='outlined'
color='primary'
onClick={onClose}
size='small'
sx={theme => ({
minWidth: 'auto',
px: 1,
py: '1px',
fontSize: 12,
fontWeight: 500,
textTransform: 'none',
color: 'text.secondary',
borderColor: alpha(theme.palette.text.primary, 0.1),
})}
>
Esc
</Button>
)}
</Box>
{/* 主内容区域 - 根据模式切换 */}
<Box
sx={{
px: 3,
flex: 1,
display: searchMode === 'chat' ? 'flex' : 'none',
flexDirection: 'column',
}}
>
<AiQaContent
hotSearch={hotSearch}
placeholder={placeholder}
inputRef={aiQaInputRef}
/>
</Box>
<Box
sx={{
px: 3,
flex: 1,
display: searchMode === 'search' ? 'flex' : 'none',
flexDirection: 'column',
}}
>
<SearchDocContent inputRef={inputRef} placeholder={placeholder} />
</Box>
{/* 底部AI生成提示 */}
<Box
sx={{
px: 3,
pt: !kbDetail?.settings?.conversation_setting
?.copyright_hide_enabled
? 2
: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant='caption'
sx={{
color: 'text.disabled',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box>
{!kbDetail?.settings?.conversation_setting
?.copyright_hide_enabled &&
(kbDetail?.settings?.conversation_setting?.copyright_info ||
'本网站由 PandaWiki 提供技术支持')}
</Box>
</Typography>
</Box>
</Box>
</Modal>
);
};
export default QaModal;

View File

@@ -0,0 +1,32 @@
import { ChunkResultItem } from '@/assets/type';
export interface ConversationItem {
q: string;
a: string;
score: number;
update_time: string;
message_id: string;
source: 'history' | 'chat';
chunk_result: ChunkResultItem[];
thinking_content: string;
}
export interface UploadedImage {
id: string;
url: string;
file: File;
}
export interface SSEMessageData {
type: string;
content: string;
chunk_result: ChunkResultItem;
}
export interface ChatRequestData {
message: string;
nonce: string;
conversation_id: string;
app_type: number;
captcha_token: string;
}

View File

@@ -0,0 +1,16 @@
export const handleThinkingContent = (content: string) => {
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
const thinkMatches = [];
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkMatches.push(match[1]);
}
let answerContent = content.replace(/<think>[\s\S]*?<\/think>/g, '');
answerContent = answerContent.replace(/<think>[\s\S]*$/, '');
return {
thinkingContent: thinkMatches.join(''),
answerContent: answerContent,
};
};

View File

@@ -0,0 +1,448 @@
'use client';
import { useBasePath } from '@/hooks';
import { postShareV1CommonFileUpload } from '@/request/ShareFile';
import { message } from '@ctzhian/ui';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import CloseIcon from '@mui/icons-material/Close';
import ImageIcon from '@mui/icons-material/Image';
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {
alpha,
Box,
IconButton,
Popover,
Stack,
TextField,
TextFieldProps,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import React, { useRef, useState } from 'react';
import zh from '../emoji/emoji-data/zh.json';
export interface ImageItem {
id: string;
url: string; // 本地预览 URL (blob URL)
file: File;
uploaded?: boolean; // 是否已上传到服务器
uploadedUrl?: string; // 上传后的服务器 URL
}
interface CommentInputProps {
value: string;
onChange: (value: string) => void;
onImagesChange?: (images: ImageItem[]) => void;
placeholder?: string;
error?: boolean;
helperText?: string;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
maxImages?: number;
textFieldProps?: Partial<TextFieldProps>;
}
export interface CommentInputRef {
uploadImages: () => Promise<string[]>; // 上传所有图片并返回 URL 列表
clearImages: () => void; // 清空图片
}
const CommentInput = React.forwardRef<CommentInputRef, CommentInputProps>(
(
{
value,
onChange,
onImagesChange,
placeholder = '请输入评论',
error,
helperText,
onFocus,
onBlur,
maxImages = 9,
textFieldProps,
},
ref,
) => {
const theme = useTheme();
const basePath = useBasePath();
const [images, setImages] = useState<ImageItem[]>([]);
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [emojiAnchorEl, setEmojiAnchorEl] =
useState<HTMLButtonElement | null>(null);
// 添加本地图片预览(不上传到服务器)
const handleImageSelect = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const remainingSlots = maxImages - images.length;
if (remainingSlots <= 0) {
message.warning(`最多只能上传 ${maxImages} 张图片`);
return;
}
const filesToAdd = Array.from(files).slice(0, remainingSlots);
try {
const newImages: ImageItem[] = [];
for (const file of filesToAdd) {
// 验证文件类型(只允许 jpg、jpeg、png、webp
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
message.error('只支持上传 jpg、jpeg、png、webp 格式的图片');
continue;
}
// 验证文件大小 (10MB)
if (file.size > 10 * 1024 * 1024) {
message.error('图片大小不能超过 10MB');
continue;
}
// 创建本地预览 URL
const localUrl = URL.createObjectURL(file);
newImages.push({
id: Date.now().toString() + Math.random(),
url: localUrl,
file,
uploaded: false,
});
}
const updatedImages = [...images, ...newImages];
setImages(updatedImages);
onImagesChange?.(updatedImages);
} catch (error: any) {
message.error(error.message || '图片选择失败');
}
};
// 上传所有图片到服务器
const uploadAllImages = async (): Promise<string[]> => {
if (images.length === 0) return [];
setUploading(true);
const uploadedUrls: string[] = [];
try {
for (const image of images) {
if (image.uploaded && image.uploadedUrl) {
// 已经上传过的图片直接使用服务器 URL
uploadedUrls.push(image.uploadedUrl);
} else {
let token = '';
try {
const Cap = (await import(`@cap.js/widget`)).default;
const cap = new Cap({
apiEndpoint: `${basePath}/share/v1/captcha/`,
});
const solution = await cap.solve();
token = solution.token;
} catch (error) {
message.error('验证失败');
setUploading(false);
return Promise.reject(error);
}
// 上传新图片
const result = await postShareV1CommonFileUpload({
file: image.file,
captcha_token: token,
});
const serverUrl = '/static-file/' + result.key;
uploadedUrls.push(serverUrl);
// 更新图片状态
image.uploaded = true;
image.uploadedUrl = serverUrl;
}
}
return uploadedUrls;
} catch (error: any) {
message.error(error.message || '图片上传失败');
throw error;
} finally {
setUploading(false);
}
};
// 清空所有图片
const clearImages = () => {
// 释放所有本地 URL
images.forEach(img => {
if (!img.uploaded && img.url.startsWith('blob:')) {
URL.revokeObjectURL(img.url);
}
});
setImages([]);
onImagesChange?.([]);
};
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
uploadImages: uploadAllImages,
clearImages,
}));
const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
e.preventDefault();
const dataTransfer = new DataTransfer();
imageFiles.forEach(file => dataTransfer.items.add(file));
await handleImageSelect(dataTransfer.files);
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleImageSelect(e.target.files);
// 重置 input value 以允许上传相同文件
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleRemoveImage = (id: string) => {
const imageToRemove = images.find(img => img.id === id);
if (
imageToRemove &&
!imageToRemove.uploaded &&
imageToRemove.url.startsWith('blob:')
) {
// 释放本地 URL
URL.revokeObjectURL(imageToRemove.url);
}
const updatedImages = images.filter(img => img.id !== id);
setImages(updatedImages);
onImagesChange?.(updatedImages);
};
const handleClickUpload = () => {
fileInputRef.current?.click();
};
const handleEmojiClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setEmojiAnchorEl(event.currentTarget);
};
const handleEmojiClose = () => {
setEmojiAnchorEl(null);
};
const handleEmojiSelect = (emoji: any) => {
const input = inputRef.current;
if (input) {
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const newValue =
value.substring(0, start) + emoji.native + value.substring(end);
onChange(newValue);
// 将光标移动到插入的表情后面
setTimeout(() => {
const newPosition = start + emoji.native.length;
input.setSelectionRange(newPosition, newPosition);
input.focus();
}, 100);
} else {
// 如果无法获取光标位置,就追加到末尾
onChange(value + emoji.native);
}
handleEmojiClose();
};
const emojiOpen = Boolean(emojiAnchorEl);
const emojiPopoverId = emojiOpen ? 'emoji-popover' : undefined;
return (
<Box>
<TextField
value={value}
onChange={e => onChange(e.target.value)}
inputRef={inputRef}
onFocus={onFocus}
onBlur={onBlur}
onPaste={handlePaste}
placeholder={placeholder}
fullWidth
multiline
minRows={2}
slotProps={{
htmlInput: {
maxLength: 1000,
},
}}
sx={{
'.MuiOutlinedInput-notchedOutline': {
border: 'none',
padding: 0,
},
'.MuiInputBase-root': {
padding: 0,
},
}}
error={error}
helperText={helperText}
{...textFieldProps}
/>
{/* 图片预览区域 */}
{images.length > 0 && (
<Stack direction='row' flexWrap='wrap' gap={1} sx={{ mt: 2, mb: 1 }}>
{images.map(image => (
<Box
key={image.id}
sx={{
position: 'relative',
width: 100,
height: 100,
borderRadius: 1,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
'&:hover .delete-btn': {
opacity: 1,
},
}}
>
<Box
component='img'
src={image.url}
alt='preview'
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<IconButton
className='delete-btn'
size='small'
onClick={() => handleRemoveImage(image.id)}
sx={{
position: 'absolute',
top: 2,
right: 2,
bgcolor: theme => alpha(theme.palette.common.black, 0.6),
color: 'white',
// opacity: 0,
transition: 'opacity 0.2s',
'&:hover': {
bgcolor: theme => alpha(theme.palette.common.black, 0.8),
},
width: 20,
height: 20,
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
</Stack>
)}
{/* 底部工具栏 */}
<Stack direction='row' alignItems='center' gap={0.5} sx={{ mt: 1 }}>
<IconButton
size='small'
onClick={handleEmojiClick}
aria-describedby={emojiPopoverId}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
},
}}
>
<InsertEmoticonIcon />
</IconButton>
<IconButton
size='small'
onClick={handleClickUpload}
disabled={uploading || images.length >= maxImages}
sx={{
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
},
}}
>
<ImageIcon />
</IconButton>
<Box
sx={{
ml: 'auto',
fontSize: 12,
color: 'text.tertiary',
}}
>
{value.length} / 1000
</Box>
</Stack>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type='file'
accept='.jpg,.jpeg,.png,.webp'
multiple
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
{/* 表情选择器 Popover */}
<Popover
id={emojiPopoverId}
open={emojiOpen}
anchorEl={emojiAnchorEl}
onClose={handleEmojiClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Picker
data={data}
set='native'
theme={theme.palette.mode === 'dark' ? 'dark' : 'light'}
locale='zh'
i18n={zh}
onEmojiSelect={handleEmojiSelect}
previewPosition='none'
searchPosition='sticky'
skinTonePosition='none'
perLine={9}
emojiSize={24}
/>
</Popover>
</Box>
);
},
);
CommentInput.displayName = 'CommentInput';
export default CommentInput;

View File

@@ -0,0 +1,133 @@
'use client';
import { useStore } from '@/provider';
import { useBasePath } from '@/hooks';
import { Modal } from '@ctzhian/ui';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import MenuIcon from '@mui/icons-material/Menu';
import {
Fab,
FormControlLabel,
Radio,
RadioGroup,
Stack,
Tooltip,
Zoom,
} from '@mui/material';
import { useParams, usePathname } from 'next/navigation';
import { useState } from 'react';
const DocFab = () => {
const pathname = usePathname();
const { id: docId } = useParams() || {};
const { kbDetail, mobile } = useStore();
const [showActions, setShowActions] = useState(false);
const [contentType, setContentType] = useState<'html' | 'md'>('html');
const [openSelectContentTypeModal, setOpenSelectContentTypeModal] =
useState(false);
const basePath = useBasePath();
if (mobile) return null;
return (
<>
<Modal
title='新建文档类型'
open={openSelectContentTypeModal}
onCancel={() => {
setOpenSelectContentTypeModal(false);
setContentType('html');
}}
onOk={() => {
setOpenSelectContentTypeModal(false);
window.open(
`${basePath}/editor?contentType=${contentType}`,
'_blank',
);
}}
>
<RadioGroup
value={contentType}
onChange={e => setContentType(e.target.value as 'html' | 'md')}
>
<FormControlLabel
value='html'
control={<Radio size='small' />}
label='富文本'
/>
<FormControlLabel
value='md'
control={<Radio size='small' />}
label='Markdown'
/>
</RadioGroup>
</Modal>
<Stack
gap={1}
sx={{
position: 'fixed',
bottom: 70,
right: 16,
zIndex: 10000,
}}
onMouseLeave={() => setShowActions(false)}
>
{kbDetail?.settings.contribute_settings?.is_enable && (
<>
<Zoom
in={showActions}
style={{ transitionDelay: showActions ? '100ms' : '0ms' }}
>
<Tooltip title='创建文档' placement='left' arrow>
<Fab
color='primary'
size='small'
onClick={() => {
setOpenSelectContentTypeModal(true);
}}
>
<AddIcon />
</Fab>
</Tooltip>
</Zoom>
{pathname.startsWith(basePath + '/node/') && (
<Zoom
in={showActions}
style={{ transitionDelay: showActions ? '40ms' : '0ms' }}
>
<Tooltip title='编辑文档' placement='left' arrow>
<Fab
color='primary'
size='small'
onClick={() => {
window.open(`${basePath}/editor/${docId}`, '_blank');
}}
>
<EditIcon />
</Fab>
</Tooltip>
</Zoom>
)}
<Fab
size='small'
sx={{
backgroundColor: 'background.paper2',
color: 'text.secondary',
'&:hover': { backgroundColor: 'background.paper2' },
}}
onMouseEnter={() => setShowActions(true)}
>
<MenuIcon
sx={{
transition: 'transform 200ms',
transform: showActions ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
</Fab>
</>
)}
</Stack>
</>
);
};
export default DocFab;

View File

@@ -0,0 +1,58 @@
'use client';
import { Box, Skeleton } from '@mui/material';
interface DocSkeletonProps {
showSummary?: boolean;
}
const DocSkeleton = ({ showSummary = false }: DocSkeletonProps) => (
<>
<Skeleton variant='rounded' width={'70%'} height={36} sx={{ mb: '10px' }} />
<Skeleton variant='rounded' width={'50%'} height={20} sx={{ mb: 4 }} />
{showSummary && (
<Box
sx={{
mb: 6,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
bgcolor: 'background.paper3',
p: '20px',
fontSize: 14,
lineHeight: '28px',
backdropFilter: 'blur(5px)',
}}
>
<Box sx={{ fontWeight: 'bold', mb: 2, lineHeight: '22px' }}>
</Box>
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' width={'30%'} height={16} />
</Box>
)}
<Skeleton
variant='rounded'
width={'20%'}
height={36}
sx={{ m: '40px 0 20px' }}
/>
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' width={'70%'} height={16} sx={{ mb: 2 }} />
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' width={'90%'} height={16} sx={{ mb: 1 }} />
<Skeleton
variant='rounded'
width={'35%'}
height={36}
sx={{ m: '40px 0 20px' }}
/>
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
</>
);
export default DocSkeleton;

View File

@@ -0,0 +1,29 @@
{
"search": "搜索",
"search_no_results_1": "哦不!",
"search_no_results_2": "没有找到相关表情",
"pick": "选择一个表情…",
"add_custom": "添加自定义表情",
"categories": {
"activity": "活动",
"custom": "自定义",
"flags": "旗帜",
"foods": "食物与饮品",
"frequent": "最近使用",
"nature": "动物与自然",
"objects": "物品",
"people": "表情与角色",
"places": "旅行与景点",
"search": "搜索结果",
"symbols": "符号"
},
"skins": {
"choose": "选择默认肤色",
"1": "默认",
"2": "白色",
"3": "偏白",
"4": "中等",
"5": "偏黑",
"6": "黑色"
}
}

View File

@@ -0,0 +1,117 @@
'use client';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { Box, IconButton, Popover, SxProps } from '@mui/material';
import React, { useCallback } from 'react';
import zh from './emoji-data/zh.json';
import {
IconWenjianjia,
IconWenjianjiaKai,
IconWenjian,
} from '@panda-wiki/icons';
interface EmojiPickerProps {
type: 1 | 2;
readOnly?: boolean;
value?: string;
collapsed?: boolean;
onChange?: (emoji: string) => void;
sx?: SxProps;
iconSx?: SxProps;
}
const EmojiPicker: React.FC<EmojiPickerProps> = ({
type,
readOnly,
value,
onChange,
collapsed,
sx,
iconSx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null,
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (readOnly) return;
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSelect = useCallback(
(emoji: any) => {
onChange?.(emoji.native);
handleClose();
},
[onChange],
);
const open = Boolean(anchorEl);
const id = open ? 'emoji-picker' : undefined;
return (
<>
<IconButton
size='small'
aria-describedby={id}
disabled={readOnly}
onClick={handleClick}
sx={{
cursor: 'pointer',
height: 28,
color: 'text.primary',
...sx,
}}
>
{value ? (
<Box component='span' sx={{ fontSize: 14, ...iconSx }}>
{value}
</Box>
) : (
<>
{type === 1 ? (
collapsed ? (
<IconWenjianjia sx={{ fontSize: 16, ...iconSx }} />
) : (
<IconWenjianjiaKai sx={{ fontSize: 16, ...iconSx }} />
)
) : (
<IconWenjian sx={{ fontSize: 16, ...iconSx }} />
)}
</>
)}
</IconButton>
<Popover
id={id}
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Picker
data={data}
set='native'
theme='light'
locale='zh'
i18n={zh}
onEmojiSelect={handleSelect}
previewPosition='none'
searchPosition='sticky'
skinTonePosition='none'
perLine={9}
emojiSize={24}
/>
</Popover>
</>
);
};
export default EmojiPicker;

View File

@@ -0,0 +1,30 @@
'use client';
import noDocImage from '@/assets/images/no-doc.png';
import { Box, Stack } from '@mui/material';
import Image from 'next/image';
interface EmptyDocPlaceholderProps {
mobile?: boolean;
}
const EmptyDocPlaceholder = ({ mobile = false }: EmptyDocPlaceholderProps) => (
<Stack
justifyContent='center'
alignItems='center'
gap={2}
sx={{
flex: 1,
pt: '50px',
pb: 10,
px: mobile ? 5 : 0,
}}
>
<Image src={noDocImage} alt='暂无文档' width={mobile ? 280 : 380} />
<Box sx={{ fontSize: 14, color: 'text.tertiary' }}>
,
</Box>
</Stack>
);
export default EmptyDocPlaceholder;

View File

@@ -0,0 +1,75 @@
'use client';
import ErrorPng from '@/assets/images/500.png';
import NoPermissionImg from '@/assets/images/no-permission.png';
import NotFoundImg from '@/assets/images/404.png';
import BlockImg from '@/assets/images/block.png';
import { SxProps, Stack } from '@mui/material';
import Image from 'next/image';
import { useStore } from '@/provider';
const CODE_MAP = {
40003: {
title: '无权限访问',
img: NoPermissionImg,
},
403: {
title: '当前网站已关闭访问',
img: BlockImg,
},
40004: {
title: '页面不存在',
img: NotFoundImg,
},
};
const DEFAULT_ERROR = {
title: '页面出错了',
img: ErrorPng,
};
export default function Error({
sx,
error,
reset,
}: {
error: Partial<Error> & { digest?: string } & { code?: number | string };
reset?: () => void;
sx?: SxProps;
}) {
const { mobile } = useStore();
const errorInfo =
CODE_MAP[(error.code ?? error.message) as '40003'] || DEFAULT_ERROR;
return (
<Stack
flex={1}
sx={{
height: '100%',
...(mobile && {
width: '100%',
marginLeft: 0,
}),
...sx,
}}
justifyContent='center'
alignItems='center'
>
<Image
src={errorInfo.img.src}
alt='404'
width={380}
height={255}
style={{
height: 'auto',
...(mobile && { width: 200 }),
}}
/>
<Stack
gap={3}
alignItems='center'
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
>
{errorInfo.title}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,146 @@
import { ConversationItem } from '@/assets/type';
import { useStore } from '@/provider';
import { Box, Stack, TextField } from '@mui/material';
import { Modal } from '@ctzhian/ui';
import { useState } from 'react';
interface FeedbackProps {
open: boolean;
onClose: () => void;
onSubmit: (
message_id: string,
score: number,
type: string,
content?: string,
) => void;
data: ConversationItem | { message_id: string } | null;
tags?: string[];
}
const Feedback = ({
open,
onClose,
onSubmit,
data,
tags: propsTags,
}: FeedbackProps) => {
const { themeMode, kbDetail } = useStore();
const [type, setType] = useState<string>('');
const [content, setContent] = useState('');
const tags: string[] =
propsTags ??
// @ts-ignore
(kbDetail?.settings?.ai_feedback_settings?.ai_feedback_type || []);
const handleCancel = () => {
setContent('');
setType('');
onClose();
};
const handleSubmit = () => {
if (!data) return;
onSubmit(data.message_id, -1, type, content);
handleCancel();
};
return (
<Modal
open={open}
onCancel={handleCancel}
title='反馈意见'
cancelText='取消'
okText='提交'
onOk={handleSubmit}
cancelButtonProps={{
sx: {
color: 'text.primary',
},
}}
>
<Stack
direction='row'
spacing={2}
sx={{
flexWrap: 'wrap',
mb: 2,
}}
>
{tags.map(tag => (
<Box
key={tag}
sx={{
py: 0.75,
px: 2,
fontSize: 12,
borderRadius: '10px',
border: '1px solid',
borderColor: type === tag ? 'primary.main' : 'divider',
cursor: 'pointer',
color: type === tag ? 'primary.main' : 'text.primary',
bgcolor:
themeMode === 'dark'
? 'background.paper3'
: 'background.default',
}}
onClick={() => {
setType(tag);
}}
>
{tag}
</Box>
))}
</Stack>
<Box
sx={{
borderRadius: '10px',
border: '1px solid',
borderColor: 'divider',
bgcolor:
themeMode === 'dark' ? 'background.paper3' : 'background.default',
p: 2,
}}
>
<TextField
fullWidth
multiline
rows={4}
size='small'
placeholder='请输入反馈内容'
value={content}
sx={{
'.MuiInputBase-root': {
p: 0,
overflow: 'hidden',
transition: 'all 0.5s ease-in-out',
bgcolor:
themeMode === 'dark'
? 'background.paper3'
: 'background.default',
},
textarea: {
lineHeight: '26px',
borderRadius: 0,
transition: 'all 0.5s ease-in-out',
'&::-webkit-scrollbar': {
display: 'none',
},
'&::placeholder': {
fontSize: 14,
},
scrollbarWidth: 'none',
msOverflowStyle: 'none',
},
fieldset: {
border: 'none',
},
}}
onChange={e => setContent(e.target.value)}
/>
</Box>
</Modal>
);
};
export default Feedback;

View File

@@ -0,0 +1,49 @@
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
import { Box, IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
interface OverlayProps {
open: boolean;
onClose: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
}
const Overlay: React.FC<OverlayProps> = ({ open, onClose, children }) => {
return (
<>
{open && (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1300,
}}
onClick={() => onClose(false)}
>
<IconButton
onClick={() => onClose(false)}
sx={{
position: 'absolute',
top: 16,
right: 16,
color: 'white',
zIndex: 1310,
}}
>
<CloseIcon />
</IconButton>
<Box onClick={e => e.stopPropagation()}>{children}</Box>
</Box>
)}
</>
);
};
export default Overlay;

View File

@@ -0,0 +1,97 @@
'use client';
import { useStore } from '@/provider';
import { useMemo } from 'react';
import { getImagePath } from '@/utils/getImagePath';
import { useBasePath } from '@/hooks';
import {
Footer,
WelcomeFooter as WelcomeFooterComponent,
} from '@panda-wiki/ui';
export const FooterProvider = ({
showBrand = true,
isDocPage = false,
isWelcomePage = false,
}: {
showBrand?: boolean;
isDocPage?: boolean;
isWelcomePage?: boolean;
}) => {
const { mobile = false, catalogWidth, kbDetail } = useStore();
const basePath = useBasePath();
const docWidth = useMemo(() => {
if (isWelcomePage) return 'full';
return kbDetail?.settings?.theme_and_style?.doc_width || 'full';
}, [kbDetail, isWelcomePage]);
const footerSetting = kbDetail?.settings?.footer_settings;
const customStyle = kbDetail?.settings?.web_app_custom_style;
return (
<Footer
mobile={mobile}
catalogWidth={catalogWidth}
showBrand={showBrand}
isDocPage={isDocPage}
logo='https://release.baizhi.cloud/panda-wiki/icon.png'
docWidth={docWidth}
footerSetting={
footerSetting
? {
...footerSetting,
brand_logo: getImagePath(footerSetting?.brand_logo, basePath),
}
: undefined
}
customStyle={{
...customStyle,
social_media_accounts: customStyle?.social_media_accounts?.map(
(item: any) => ({
...item,
icon: getImagePath(item.icon, basePath),
}),
),
}}
/>
);
};
export const WelcomeFooter = ({
showBrand = true,
}: {
showBrand?: boolean;
}) => {
const { mobile = false, catalogWidth, kbDetail } = useStore();
const basePath = useBasePath();
const footerSetting = kbDetail?.settings?.footer_settings;
const customStyle = kbDetail?.settings?.web_app_custom_style;
return (
<WelcomeFooterComponent
mobile={mobile}
catalogWidth={catalogWidth}
showBrand={showBrand}
isDocPage={false}
logo='https://release.baizhi.cloud/panda-wiki/icon.png'
docWidth='full'
footerSetting={
footerSetting
? {
...footerSetting,
brand_logo: getImagePath(footerSetting?.brand_logo, basePath),
}
: undefined
}
customStyle={{
...customStyle,
social_media_accounts: customStyle?.social_media_accounts?.map(
(item: any) => ({
...item,
icon: getImagePath(item.icon, basePath),
}),
),
}}
/>
);
};
export default Footer;

View File

@@ -0,0 +1,181 @@
'use client';
import Logo from '@/assets/images/logo.png';
import { useBasePath } from '@/hooks';
import { useStore } from '@/provider';
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
import { getImagePath } from '@/utils/getImagePath';
import { Modal } from '@ctzhian/ui';
import ErrorIcon from '@mui/icons-material/Error';
import { alpha, Box, IconButton, Stack, Tooltip } from '@mui/material';
import { IconDengchu } from '@panda-wiki/icons';
import {
Header as CustomHeader,
WelcomeHeader as WelcomeHeaderComponent,
} from '@panda-wiki/ui';
import { useMemo, useState } from 'react';
import QaModal from '../QaModal';
import ThemeSwitch from './themeSwitch';
interface HeaderProps {
isDocPage?: boolean;
isWelcomePage?: boolean;
}
const LogoutButton = () => {
const [open, setOpen] = useState(false);
const handleLogout = () => {
return postShareProV1AuthLogout().then(() => {
// 使用当前页面的协议http 或 https
const protocol = window.location.protocol;
const host = window.location.host;
window.location.href = `${protocol}//${host}/auth/login`;
});
};
return (
<>
<Modal
title={
<Stack direction='row' alignItems='center' gap={1}>
<ErrorIcon sx={{ fontSize: 24, color: 'warning.main' }} />
<Box sx={{ mt: '2px' }}></Box>
</Stack>
}
open={open}
okText='确定'
cancelText='取消'
onCancel={() => setOpen(false)}
onOk={handleLogout}
closable={false}
>
<Box sx={{ pl: 4 }}>退</Box>
</Modal>
<Tooltip title='退出登录' arrow>
<IconButton size='small' onClick={() => setOpen(true)}>
<IconDengchu
sx={theme => ({
cursor: 'pointer',
color: alpha(theme.palette.text.primary, 0.65),
fontSize: 24,
'&:hover': { color: theme.palette.primary.main },
})}
/>
</IconButton>
</Tooltip>
</>
);
};
const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const basePath = useBasePath();
const docWidth = useMemo(() => {
if (isWelcomePage) return 'full';
return kbDetail?.settings?.theme_and_style?.doc_width || 'full';
}, [kbDetail, isWelcomePage]);
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
if (value?.trim()) {
if (type === 'chat') {
sessionStorage.setItem('chat_search_query', value.trim());
setQaModalOpen?.(true);
} else {
sessionStorage.setItem('chat_search_query', value.trim());
}
}
};
return (
<CustomHeader
isDocPage={isDocPage}
mobile={mobile}
docWidth={docWidth}
catalogWidth={catalogWidth}
logo={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
title={kbDetail?.settings?.title}
placeholder={
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder
}
showSearch
homePath={basePath || '/'}
btns={
kbDetail?.settings?.btns?.map((item: any) => ({
...item,
url: getImagePath(item.url, basePath),
icon: getImagePath(item.icon, basePath),
})) || []
}
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
<ThemeSwitch />
{!!authInfo && <LogoutButton />}
</Stack>
<QaModal />
</CustomHeader>
);
};
export const WelcomeHeader = ({
showSearch = true,
}: {
showSearch?: boolean;
}) => {
const basePath = useBasePath();
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
if (value?.trim()) {
if (type === 'chat') {
sessionStorage.setItem('chat_search_query', value.trim());
setQaModalOpen?.(true);
} else {
sessionStorage.setItem('chat_search_query', value.trim());
}
}
};
return (
<WelcomeHeaderComponent
isDocPage={false}
mobile={mobile}
docWidth='full'
catalogWidth={catalogWidth}
logo={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
title={kbDetail?.settings?.title}
placeholder={
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder
}
showSearch={showSearch}
homePath={basePath || '/'}
btns={
kbDetail?.settings?.btns?.map((item: any) => ({
...item,
url: getImagePath(item.url, basePath),
icon: getImagePath(item.icon, basePath),
})) || []
}
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
{!!authInfo && (
<Box sx={{ ml: 2 }}>
<LogoutButton />
</Box>
)}
<QaModal />
</WelcomeHeaderComponent>
);
};
export default Header;

View File

@@ -0,0 +1,23 @@
import { IconButton, alpha } from '@mui/material';
import { IconShensemoshi, IconMingliangmoshi } from '@panda-wiki/icons';
import { useThemeStore } from '@/provider/themeStore';
const ThemeSwitch = () => {
const { themeMode, setThemeMode } = useThemeStore();
return (
<IconButton
size='small'
onClick={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
>
{themeMode === 'dark' ? (
<IconShensemoshi
sx={theme => ({ color: alpha(theme.palette.text.primary, 0.65) })}
/>
) : (
<IconMingliangmoshi sx={{ fontSize: 20 }} />
)}
</IconButton>
);
};
export default ThemeSwitch;

View File

@@ -0,0 +1,145 @@
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
export const IconNav = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M915 556H334.782c-60 0-60-90 0-90H915c60 0 60 90 0 90z m-0.377 371H334.405c-60 0-60-90 0-90h580.218c60 0 60 90 0 90z m0-741H334.405c-60 0-60-90 0-90h580.218c60 0 60 90 0 90zM128 206c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64 35.346 0 64 28.654 64 64 0 35.346-28.654 64-64 64z m0 741c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64 35.346 0 64 28.654 64 64 0 35.346-28.654 64-64 64z m0-371c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64 35.346 0 64 28.654 64 64 0 35.346-28.654 64-64 64z'></path>
</SvgIcon>
);
};
IconNav.displayName = 'icon-daohangfenlei';
export const IconMore = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M66.488889 211.781818h891.022222c28.198788 0 50.980202-22.238384 50.980202-49.648485 0-27.397172-22.768485-49.648485-50.980202-49.648485H66.488889C38.341818 112.484848 15.508687 134.723232 15.508687 162.133333s22.833131 49.648485 50.980202 49.648485z m891.009293 248.242424H66.488889C38.277172 460.024242 15.508687 482.262626 15.508687 509.672727s22.768485 49.648485 50.980202 49.648485h891.022222c28.198788 0 50.980202-22.238384 50.980202-49.648485-0.012929-27.410101-22.923636-49.648485-50.993131-49.648485z m0 351.63798H66.488889c-28.134141 0-50.980202 22.238384-50.980202 49.648485s22.833131 49.648485 50.980202 49.648485h891.022222c28.198788 0 50.980202-22.238384 50.980202-49.648485-0.012929-27.397172-22.781414-49.648485-50.993131-49.648485z m0 0'></path>
</SvgIcon>
);
};
IconMore.displayName = 'icon-more';
export const IconClose = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M583.125118 510.018369l423.729537-422.410436a51.062005 51.062005 0 0 0-72.08253-72.720805l-423.772089 422.580642-420.155198-422.367884a51.062005 51.062005 0 1 0-72.33784 72.33784l419.942439 422.282781-423.431676 422.240229a51.317315 51.317315 0 0 0 0 72.33784 51.062005 51.062005 0 0 0 72.33784 0l423.601883-422.325332 423.899744 426.197534a51.062005 51.062005 0 0 0 72.337841 0 51.359867 51.359867 0 0 0 0-72.33784l-423.814641-426.197534m0 0z'></path>
</SvgIcon>
);
};
IconClose.displayName = 'icon-close';
export const IconDingDing = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M512.003 79C272.855 79 79 272.855 79 512.003 79 751.145 272.855 945 512.003 945 751.145 945 945 751.145 945 512.003 945 272.855 751.145 79 512.003 79z m200.075 375.014c-0.867 3.764-3.117 9.347-6.234 16.012h0.087l-0.347 0.648c-18.183 38.86-65.631 115.108-65.631 115.108l-0.215-0.52-13.856 24.147h66.8L565.063 779l29.002-115.368h-52.598l18.27-76.29c-14.76 3.55-32.253 8.436-52.945 15.1 0 0-27.967 16.36-80.607-31.5 0 0-35.501-31.29-14.891-39.078 8.744-3.33 42.466-7.573 69.004-11.122 35.93-4.845 57.965-7.441 57.965-7.441s-110.607 1.643-136.841-2.468c-26.237-4.11-59.525-47.905-66.626-86.377 0 0-10.953-21.117 23.595-11.122 34.547 10 177.535 38.95 177.535 38.95s-185.933-56.992-198.36-70.929c-12.381-13.846-36.406-75.902-33.289-113.981 0 0 1.343-9.521 11.127-6.926 0 0 137.49 62.75 231.475 97.152 94.028 34.403 175.76 51.885 165.2 96.414z'
fill='#3AA2EB'
p-id='6525'
></path>
</SvgIcon>
);
};
IconDingDing.displayName = 'icon-dingding';
export const IconQiyeweixin = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1229 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M702.72 849.92c-76.8 30.72-158.72 35.84-240.64 30.72-35.84-5.12-71.68-10.24-107.52-20.48-5.12 0-10.24 0-15.36 5.12-46.08 20.48-92.16 46.08-133.12 66.56-15.36 10.24-30.72 10.24-46.08 0s-15.36-25.6-15.36-46.08c10.24-35.84 10.24-71.68 15.36-107.52 0-5.12-5.12-10.24-5.12-15.36-51.2-51.2-92.16-102.4-122.88-168.96-51.2-122.88-40.96-245.76 30.72-358.4C134.4 112.64 247.04 46.08 380.16 15.36S641.28 0 764.16 61.44c112.64 56.32 194.56 143.36 230.4 266.24 15.36 46.08 20.48 92.16 15.36 138.24-25.6-25.6-56.32-30.72-87.04-15.36 0-30.72 0-61.44-10.24-92.16-20.48-71.68-61.44-128-112.64-174.08-87.04-71.68-194.56-102.4-307.2-102.4-117.76 10.24-220.16 51.2-302.08 133.12-66.56 66.56-102.4 148.48-97.28 245.76 5.12 81.92 40.96 148.48 92.16 204.8l40.96 40.96c20.48 15.36 25.6 30.72 15.36 51.2-5.12 20.48-10.24 46.08-15.36 66.56 0 5.12-5.12 10.24 0 10.24 5.12 5.12 10.24 0 10.24 0 25.6-15.36 56.32-30.72 81.92-51.2 15.36-10.24 30.72-10.24 51.2-5.12 87.04 25.6 179.2 25.6 266.24 0 5.12 0 10.24-5.12 10.24 5.12 10.24 30.72 25.6 51.2 56.32 66.56z'
fill='#0082EF'
p-id='1546'
></path>
<path
d='M1214.72 747.52c0 35.84-25.6 61.44-56.32 66.56-51.2 10.24-92.16 30.72-128 66.56-10.24 10.24-15.36 10.24-25.6 5.12-5.12-5.12-5.12-15.36 0-25.6 35.84-35.84 56.32-81.92 66.56-128 5.12-35.84 40.96-56.32 76.8-56.32 40.96 5.12 66.56 35.84 66.56 71.68z'
fill='#0081EE'
p-id='1547'
></path>
<path
d='M953.6 1024c-35.84 0-66.56-25.6-71.68-56.32-5.12-51.2-30.72-92.16-66.56-122.88-5.12-5.12-10.24-10.24-5.12-20.48 5.12-15.36 15.36-15.36 25.6-10.24 10.24 5.12 15.36 15.36 20.48 20.48 30.72 25.6 66.56 40.96 102.4 46.08 35.84 5.12 61.44 40.96 56.32 76.8 5.12 35.84-25.6 66.56-61.44 66.56z'
fill='#FA6202'
p-id='1548'
></path>
<path
d='M682.24 757.76c0-35.84 20.48-61.44 56.32-71.68 51.2-10.24 92.16-30.72 128-66.56 10.24-10.24 20.48-10.24 25.6 0 5.12 5.12 5.12 15.36-5.12 25.6-30.72 30.72-51.2 66.56-61.44 112.64 0 5.12 0 15.36-5.12 20.48-10.24 35.84-40.96 56.32-76.8 51.2-35.84-5.12-61.44-35.84-61.44-71.68z'
fill='#FECD00'
p-id='1549'
></path>
<path
d='M1035.52 578.56c15.36 30.72 30.72 56.32 51.2 76.8 10.24 10.24 10.24 20.48 5.12 25.6-5.12 10.24-15.36 10.24-25.6 0-25.6-30.72-61.44-51.2-97.28-61.44-10.24-5.12-20.48-5.12-30.72-5.12-20.48-5.12-40.96-15.36-46.08-40.96-10.24-25.6-10.24-51.2 10.24-71.68 20.48-25.6 46.08-30.72 71.68-25.6 25.6 10.24 46.08 25.6 51.2 56.32 0 15.36 5.12 30.72 10.24 46.08z'
fill='#2CBD00'
p-id='1550'
></path>
</SvgIcon>
);
};
IconQiyeweixin.displayName = 'IconQiyeweixin';
export const IconCopy = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M853.333333 981.333333h-384c-72.533333 0-128-55.466667-128-128v-384c0-72.533333 55.466667-128 128-128h384c72.533333 0 128 55.466667 128 128v384c0 72.533333-55.466667 128-128 128z m-384-554.666666c-25.6 0-42.666667 17.066667-42.666666 42.666666v384c0 25.6 17.066667 42.666667 42.666666 42.666667h384c25.6 0 42.666667-17.066667 42.666667-42.666667v-384c0-25.6-17.066667-42.666667-42.666667-42.666666h-384z'
p-id='8024'
></path>
<path
d='M213.333333 682.666667H170.666667c-72.533333 0-128-55.466667-128-128V170.666667c0-72.533333 55.466667-128 128-128h384c72.533333 0 128 55.466667 128 128v42.666666c0 25.6-17.066667 42.666667-42.666667 42.666667s-42.666667-17.066667-42.666667-42.666667V170.666667c0-25.6-17.066667-42.666667-42.666666-42.666667H170.666667c-25.6 0-42.666667 17.066667-42.666667 42.666667v384c0 25.6 17.066667 42.666667 42.666667 42.666666h42.666666c25.6 0 42.666667 17.066667 42.666667 42.666667s-17.066667 42.666667-42.666667 42.666667z'
p-id='8025'
></path>
</SvgIcon>
);
};
IconCopy.displayName = 'IconCopy';
export const IconCAS = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M523.776 86.016l-3.072-0.512v0.512L149.504 184.32l21.504 392.704c0 2.56 0 52.224 15.872 81.92 79.872 150.016 268.8 241.152 329.728 268.288 0 0 2.048 1.024 3.584 1.536v1.024c0.512 0 1.024-0.512 1.536-0.512 0.512 0 1.536 0.512 1.536 0.512v-1.024c1.536-0.512 2.56-1.536 3.072-1.536 60.416-27.136 249.856-118.272 329.728-268.288 15.872-29.696 17.408-79.36 17.408-81.92l22.016-392.704-371.712-98.304zM302.08 700.416V243.2h425.472v206.336c-13.312-7.68-27.648-13.312-42.496-16.896V283.136h-343.04v374.784h172.544c8.704 16.384 20.48 30.72 34.816 42.496H302.08z m193.536-109.056H400.384v-39.936h98.304c-2.048 10.24-3.072 20.992-3.072 31.744-0.512 3.072-0.512 5.632 0 8.192zM400.384 482.304v-39.936h185.344c-20.48 9.216-38.4 23.04-52.736 39.936H400.384z m0-109.056v-39.936h236.544v39.936H400.384z m250.88 346.112c-74.24 0-134.656-60.416-134.656-134.656s60.416-134.656 134.656-134.656 134.656 60.416 134.656 134.656-60.416 134.656-134.656 134.656z m0-233.472c-54.272 0-98.304 44.032-98.304 98.304s44.032 98.304 98.304 98.304 98.304-44.032 98.304-98.304-44.032-98.304-98.304-98.304z m46.08 151.552l-19.456-8.192-8.192 19.456-17.92-42.496-17.408 42.496-8.192-19.456-19.456 8.192 16.384-39.424-22.016-36.352 25.6-42.496h51.2l25.6 42.496-22.528 37.376 16.384 38.4z'
fill='#63BA4D'
p-id='8571'
></path>
</SvgIcon>
);
};
IconCAS.displayName = 'IconCAS';

View File

@@ -0,0 +1,273 @@
import { useStore } from '@/provider';
import { addOpacityToColor, copyText } from '@/utils';
import { Box, Dialog, IconButton, useTheme } from '@mui/material';
import 'katex/dist/katex.min.css';
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { anOldHope } from 'react-syntax-highlighter/dist/esm/styles/hljs';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import MermaidDiagram from './mermaid';
import { IconXiajiantou } from '@panda-wiki/icons';
interface MarkDownProps {
loading?: boolean;
content: string;
}
const MarkDown = ({ loading = false, content }: MarkDownProps) => {
const theme = useTheme();
const { themeMode = 'light' } = useStore();
const [showThink, setShowThink] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImgSrc, setPreviewImgSrc] = useState('');
let answer = content;
if (!answer.includes('\n\n</think>')) {
const idx = answer.indexOf('\n</think>');
if (idx !== -1) {
answer = content.slice(0, idx) + '\n\n</think>' + content.slice(idx + 9);
}
}
if (content.length === 0) return null;
return (
<Box
className={`markdown-body ${themeMode === 'dark' ? 'md-dark' : ''}`}
sx={{
fontSize: '14px',
background: 'transparent',
'#chat-thinking': {
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
fontSize: '12px',
color: 'text.tertiary',
marginBottom: '40px',
lineHeight: '20px',
bgcolor: 'background.paper3',
padding: '16px',
cursor: 'pointer',
borderRadius: '10px',
div: {
transition: 'height 0.3s',
overflow: 'hidden',
height: showThink ? 'auto' : '60px',
},
},
// LaTeX公式样式
'.katex': {
display: 'inline-block',
fontSize: '24px',
py: 2,
color: 'text.primary',
},
'.katex-display': {
textAlign: 'center',
margin: '1em 0',
'& > .katex': {
display: 'inline-block',
fontSize: '20px',
py: 2,
color: 'text.primary',
},
},
// 暗色主题下的LaTeX样式
...(themeMode === 'dark' && {
'.katex': {
color: theme.palette.text.primary,
},
'.katex .mord': {
color: theme.palette.text.primary,
},
'.katex .mrel': {
color: theme.palette.text.primary,
},
'.katex .mop': {
color: theme.palette.text.primary,
},
'.katex .mbin': {
color: theme.palette.text.primary,
},
'.katex .mpunct': {
color: theme.palette.text.primary,
},
'.katex .mopen, .katex .mclose': {
color: theme.palette.text.primary,
},
}),
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]}
rehypePlugins={[
rehypeRaw,
[
rehypeSanitize,
{
tagNames: [
...(defaultSchema.tagNames! as string[]),
'think',
'error',
],
},
],
rehypeKatex,
]}
components={{
// @ts-ignore
think: (props: React.HTMLAttributes<HTMLElement>) => {
return (
<div id='chat-thinking'>
<div
className={!showThink ? 'three-ellipsis' : ''}
{...props}
></div>
{!loading && (
<IconButton
size='small'
onClick={() => setShowThink(!showThink)}
sx={{
bgcolor: 'background.paper3',
':hover': {
bgcolor: addOpacityToColor(
theme.palette.primary.main,
0.1,
),
color: theme.palette.primary.main,
},
}}
>
<IconXiajiantou
sx={{
fontSize: 18,
flexShrink: 0,
transform: showThink
? 'rotate(-180deg)'
: 'rotate(0deg)',
}}
/>
</IconButton>
)}
</div>
);
},
error: (props: React.HTMLAttributes<HTMLElement>) => {
return <span className='chat-error' {...props}></span>;
},
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 {...props} />
),
a: ({
children,
style,
...rest
}: React.HTMLAttributes<HTMLAnchorElement>) => (
<a
{...rest}
target='_blank'
rel='noopener noreferrer'
style={{
color: theme.palette.primary.main,
textDecoration: 'underline',
...style,
}}
>
{children}
</a>
),
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
const { style, alt, src, width, height, ...rest } = props;
const handleClick = () => {
setPreviewImgSrc(src as string);
setPreviewOpen(true);
};
return (
<img
alt={alt || 'markdown-img'}
src={src || ''}
{...rest}
style={{
width: width || 'auto',
height: height || 'auto',
...style,
borderRadius: '10px',
marginLeft: '5px',
boxShadow: '0px 0px 3px 1px rgba(0,0,5,0.15)',
cursor: 'pointer',
}}
onClick={handleClick}
referrerPolicy='no-referrer'
/>
);
},
code({
children,
className,
style,
...rest
}: React.HTMLAttributes<HTMLElement>) {
const match = /language-(\w+)/.exec(className || '');
if (match?.[1] === 'mermaid') {
return <MermaidDiagram chart={String(children)} />;
}
return match ? (
<SyntaxHighlighter
showLineNumbers
{...rest}
language={match[1] || 'bash'}
style={{ ...anOldHope, hljs: {} }}
onClick={() => {
copyText(String(children).replace(/\n$/, ''));
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code
{...rest}
className={className}
onClick={() => {
copyText(String(children));
}}
>
{children}
</code>
);
},
}}
>
{answer}
</ReactMarkdown>
<Dialog
sx={{
'.MuiDialog-paper': {
maxWidth: '95vw',
maxHeight: '95vh',
},
}}
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
}}
>
<img
onClick={() => {
setPreviewOpen(false);
}}
src={previewImgSrc}
alt='preview'
style={{ width: '100%', height: '100%' }}
/>
</Dialog>
</Box>
);
};
export default MarkDown;

View File

@@ -0,0 +1,51 @@
// components/MermaidDiagram.jsx
'use client'; // 必须在客户端组件中使用
import { useEffect, useRef } from 'react';
import mermaid from 'mermaid';
const MERMAID_CONFIG = {
startOnLoad: false,
theme: 'default' as const,
securityLevel: 'loose' as const,
fontFamily: 'inherit',
suppressErrorRendering: true,
};
const MermaidDiagram = ({ chart }: { chart: string }) => {
const containerRef = useRef<HTMLDivElement>(null);
const isMermaidInitialized = useRef(false);
useEffect(() => {
if (!containerRef.current || !chart) return;
// 初始化 Mermaid
if (!isMermaidInitialized.current) {
mermaid.initialize(MERMAID_CONFIG);
isMermaidInitialized.current = true;
}
// 清理容器
containerRef.current.innerHTML = '';
const renderDiagram = async () => {
try {
const id = `mermaid-${Date.now()}`;
const { svg } = await mermaid.render(id, chart);
if (svg && containerRef.current) {
containerRef.current.innerHTML = svg;
}
} catch (error: any) {
// 在渲染错误时显示简单文本表示
if (containerRef.current) {
containerRef.current.innerHTML = `<div>流程图渲染错误: ${error?.message}</div>`;
}
}
};
renderDiagram();
}, [chart]);
return <div ref={containerRef} className='mermaid-container' />;
};
export default MermaidDiagram;

View File

@@ -0,0 +1,311 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { styled, SvgIcon, SvgIconProps } from '@mui/material';
// ==================== 图片数据缓存工具函数 ====================
// 下载图片并转换为 blob URL
const fetchImageAsBlob = async (
src: string,
imageBlobCache: Map<string, string>,
): Promise<string> => {
// 检查缓存
if (imageBlobCache.has(src)) {
return imageBlobCache.get(src)!;
}
try {
const response = await fetch(src, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// 缓存 blob URL
imageBlobCache.set(src, blobUrl);
return blobUrl;
} catch (error) {
console.error('Error fetching image as blob:', error);
throw error;
}
};
// 清理图片 blob 缓存
export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
imageBlobCache.forEach(url => {
URL.revokeObjectURL(url);
});
imageBlobCache.clear();
};
const StyledErrorContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1, 6),
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
borderStyle: 'none',
borderRadius: '10px',
marginLeft: '5px',
cursor: 'pointer',
boxSizing: 'content-box' as const,
backgroundColor: 'var(--mui-palette-background-default)',
border: `1px dashed var(--mui-palette-divider)`,
color: 'var(--mui-palette-text-tertiary)',
fontSize: '14px',
}));
const StyledErrorText = styled('div')(() => ({
fontSize: '12px',
marginBottom: 10,
}));
export const ImageErrorIcon = (props: SvgIconProps) => {
return (
<SvgIcon
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M520 672L256 413.44l-109.76 93.76V246.72h261.12a41.6 41.6 0 1 0 0-82.88H104.64A41.6 41.6 0 0 0 64 205.44V800a41.6 41.6 0 0 0 41.6 41.6h267.84a40.96 40.96 0 0 0 32-67.52h21.76z'
p-id='4874'
></path>
<path
d='M952 211.52a41.92 41.92 0 0 0-28.48-15.68l-310.08-32a41.6 41.6 0 0 0-8.32 82.88l267.2 27.52-55.04 411.84-113.28-160-99.2 17.92 42.56 96-123.84 123.52-32-1.6a41.6 41.6 0 1 0-4.16 82.88l352 17.92h1.92a41.28 41.28 0 0 0 41.28-35.84L960 242.88a42.56 42.56 0 0 0-8-31.36z'
p-id='4875'
></path>
<path
d='M695.36 397.44m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z'
p-id='4876'
></path>
</SvgIcon>
);
};
// 错误展示组件
const ImageErrorDisplay: React.FC = () => (
<StyledErrorContainer>
<ImageErrorIcon
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 140 }}
/>
<StyledErrorText></StyledErrorText>
</StyledErrorContainer>
);
// ==================== 类型定义 ====================
interface ImageComponentProps {
src: string;
alt: string;
attrs: [string, string][];
imageIndex: number;
onLoad: (index: number, html: string) => void;
onError: (index: number, html: string) => void;
imageBlobCache: Map<string, string>;
}
// ==================== 图片组件 ====================
const ImageComponent: React.FC<ImageComponentProps> = ({
src,
alt,
attrs,
imageIndex,
onLoad,
onError,
imageBlobCache,
}) => {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading',
);
const [blobUrl, setBlobUrl] = useState<string>('');
// 基础样式对象
const baseStyleObj = {
borderStyle: 'none' as const,
borderRadius: '10px',
marginLeft: '5px',
boxShadow: '0px 0px 3px 1px rgba(0,0,5,0.15)',
cursor: 'pointer',
maxWidth: '60%',
boxSizing: 'content-box' as const,
backgroundColor: 'var(--color-canvas-default)',
};
// 获取图片 blob URL
useEffect(() => {
let mounted = true;
fetchImageAsBlob(src, imageBlobCache)
.then(url => {
if (mounted) {
setBlobUrl(url);
}
})
.catch(err => {
console.error('Failed to fetch image blob:', err);
if (mounted) {
// 如果获取 blob 失败,回退到使用原始 URL
setBlobUrl(src);
}
});
return () => {
mounted = false;
};
}, [src, imageBlobCache]);
// 解析自定义样式
const parseStyleString = (styleStr: string) => {
if (!styleStr) return {};
const styleObj: Record<string, string> = {};
const declarations = styleStr.split(';').filter(Boolean);
declarations.forEach(decl => {
const [prop, value] = decl.split(':').map(s => s.trim());
if (prop && value) {
const camelProp = prop.replace(/-([a-z])/g, (_, letter) =>
letter.toUpperCase(),
);
styleObj[camelProp] = value;
}
});
return styleObj;
};
// 获取其他属性
const getOtherProps = () => {
const props: Record<string, string> = {};
const customStyle = attrs.find(([name]) => name === 'style')?.[1] || '';
attrs.forEach(([name, value]) => {
if (!['src', 'alt', 'style'].includes(name)) {
props[name] = value;
}
});
return {
...props,
style: {
...baseStyleObj,
...parseStyleString(customStyle),
} as React.CSSProperties,
};
};
const handleLoad = () => {
setStatus('success');
const classname = `.image-container-${imageIndex}`;
const containerDom = document.querySelector(classname);
if (containerDom) {
onLoad(imageIndex, containerDom.outerHTML);
}
};
const handleError = () => {
setStatus('error');
// 通知父组件错误状态
const classname = `.image-container-${imageIndex}`;
const containerDom = document.querySelector(classname);
if (containerDom) {
requestAnimationFrame(() => {
onError(imageIndex, containerDom.outerHTML);
});
}
};
return (
<>
{status === 'error' ? (
<ImageErrorDisplay />
) : blobUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={blobUrl}
alt={alt || 'markdown-img'}
referrerPolicy='no-referrer'
onLoad={handleLoad}
onError={handleError}
data-original-src={src}
className='markdown-image'
{...getOtherProps()}
/>
) : (
// 加载中显示占位符
<div
style={{
...baseStyleObj,
minHeight: '100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
}}
>
...
</div>
)}
</>
);
};
// ==================== 图片渲染器 ====================
export interface ImageRendererOptions {
onImageLoad: (index: number, html: string) => void;
onImageError: (index: number, html: string) => void;
imageRenderCache: Map<number, string>;
imageBlobCache: Map<string, string>;
}
export const createImageRenderer = (options: ImageRendererOptions) => {
const { onImageLoad, onImageError, imageRenderCache, imageBlobCache } =
options;
return (
src: string,
alt: string,
attrs: [string, string][] = [],
imageIndex: number,
) => {
// 检查缓存
const cached = imageRenderCache.get(imageIndex);
if (cached) {
return cached;
}
// 直接返回占位符,让 React 组件在 DOM 中渲染
const placeholderHtml = `<div class="image-container image-container-${imageIndex}"></div>`;
// 使用 requestAnimationFrame 确保在下一帧渲染时执行
requestAnimationFrame(() => {
const placeholder = document.querySelector(
`.image-container-${imageIndex}`,
);
if (placeholder) {
const root = createRoot(placeholder);
root.render(
<ImageComponent
src={src}
alt={alt}
attrs={attrs}
imageIndex={imageIndex}
onLoad={onImageLoad}
onError={onImageError}
imageBlobCache={imageBlobCache}
/>,
);
} else {
console.warn(`Placeholder with index ${imageIndex} not found`);
}
});
return placeholderHtml;
};
};

View File

@@ -0,0 +1,97 @@
/**
* 增量渲染器 - 只更新变化的DOM部分以避免闪烁
*/
interface DiffResult {
type: 'add' | 'remove' | 'modify' | 'same';
element?: Element;
newHtml?: string;
index: number;
}
/**
* 简单的HTML差异检测
*/
function findHtmlDiffs(
oldHtml: string,
newHtml: string,
): { shouldUpdate: boolean; diffs: DiffResult[] } {
// 如果完全相同,不需要更新
if (oldHtml === newHtml) {
return { shouldUpdate: false, diffs: [] };
}
// 对于复杂的差异检测,这里使用简化版本
// 在实际项目中,可以使用更复杂的算法如 Myers diff 或 virtual DOM diff
const oldLength = oldHtml.length;
const newLength = newHtml.length;
// 如果新内容更长,说明有新增内容
if (newLength > oldLength && newHtml.startsWith(oldHtml)) {
return {
shouldUpdate: true,
diffs: [
{
type: 'add',
newHtml: newHtml.slice(oldLength),
index: oldLength,
},
],
};
}
// 否则进行完整更新
return {
shouldUpdate: true,
diffs: [
{
type: 'modify',
newHtml: newHtml,
index: 0,
},
],
};
}
/**
* 主要的增量渲染函数
*/
export function incrementalRender(
container: HTMLElement,
newHtml: string,
oldContent: string,
): void {
if (!container) return;
const oldHtml = container.innerHTML;
const diffs = findHtmlDiffs(oldHtml, newHtml);
if (!diffs.shouldUpdate) {
return;
}
try {
// 对于简单的追加情况
if (diffs.diffs.length === 1 && diffs.diffs[0].type === 'add') {
const diff = diffs.diffs[0];
if (diff.newHtml) {
// 创建临时容器解析新HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = diff.newHtml;
// 将新元素追加到容器
while (tempDiv.firstChild) {
container.appendChild(tempDiv.firstChild);
}
}
} else {
// 完整更新
container.innerHTML = newHtml;
}
} catch (error) {
console.error('增量渲染错误:', error);
// 降级到完整渲染
container.innerHTML = newHtml;
}
}

View File

@@ -0,0 +1,574 @@
'use client';
import { useSmartScroll } from '@/hooks';
import { copyText } from '@/utils';
import { getImagePath } from '@/utils/getImagePath';
import { Box, Dialog, useTheme } from '@mui/material';
import mk from '@vscode/markdown-it-katex';
import hljs from 'highlight.js';
import 'highlight.js/styles/an-old-hope.css';
import 'katex/dist/katex.min.css';
import MarkdownIt from 'markdown-it';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { clearImageBlobCache, createImageRenderer } from './imageRenderer';
import { incrementalRender } from './incrementalRenderer';
import { createMermaidRenderer } from './mermaidRenderer';
import {
processThinkingContent,
useThinkingRenderer,
} from './thinkingRenderer';
// ==================== 类型定义 ====================
interface MarkDown2Props {
loading?: boolean;
content: string;
autoScroll?: boolean;
}
// ==================== 工具函数 ====================
/**
* 创建 MarkdownIt 实例
*/
const createMarkdownIt = (): MarkdownIt => {
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
highlight: (str: string, lang: string): string => {
if (lang && hljs.getLanguage(lang)) {
try {
const highlighted = hljs.highlight(str, { language: lang });
return `<pre class="hljs" style="cursor: pointer;"><code class="language-${lang}">${highlighted.value}</code></pre>`;
} catch {
// 处理高亮失败的情况
}
}
return `<pre class="hljs" style="cursor: pointer;"><code>${md.utils.escapeHtml(
str,
)}</code></pre>`;
},
});
// 添加 KaTeX 数学公式支持
try {
// 由于 @vscode/markdown-it-katex 和 markdown-it 类型版本不一致,这里通过 any 断言绕过类型不兼容
(md as any).use(mk as any);
} catch (error) {
console.warn('markdown-it-katex not available:', error);
}
return md;
};
// ==================== 主组件 ====================
const MarkDown2: React.FC<MarkDown2Props> = ({
loading = false,
content,
autoScroll = true,
}) => {
const theme = useTheme();
const themeMode = theme.palette.mode;
// 状态管理
const [showThink, setShowThink] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImgBlobUrl, setPreviewImgBlobUrl] = useState('');
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const lastContentRef = useRef<string>('');
const mdRef = useRef<MarkdownIt | null>(null);
const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map());
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存HTML
const imageBlobCacheRef = useRef<Map<string, string>>(new Map()); // 图片 blob URL 缓存
// 使用智能滚动 hook
const { scrollToBottom } = useSmartScroll({
container: '.conversation-container',
threshold: 50, // 距离底部 50px 内认为是在底部附近
behavior: 'smooth',
enabled: autoScroll,
});
const handleThinkToggle = useCallback(() => {
setShowThink(prev => !prev);
}, []);
// ==================== 渲染器函数 ====================
/**
* 处理图片加载成功
*/
const handleImageLoad = useCallback((index: number, html: string) => {
imageRenderCacheRef.current.set(index, html);
// 图片加载完成后useSmartScroll 的 ResizeObserver 会自动触发滚动
}, []);
/**
* 处理图片加载失败
*/
const handleImageError = useCallback((index: number, html: string) => {
imageRenderCacheRef.current.set(index, html);
// 图片加载失败后useSmartScroll 的 ResizeObserver 会自动触发滚动
}, []);
// 创建图片渲染器
const renderImage = useMemo(
() =>
createImageRenderer({
onImageLoad: handleImageLoad,
onImageError: handleImageError,
imageRenderCache: imageRenderCacheRef.current,
imageBlobCache: imageBlobCacheRef.current,
}),
[handleImageLoad, handleImageError],
);
// 创建thinking渲染器
const renderThinking = useThinkingRenderer({
showThink,
onToggle: handleThinkToggle,
loading,
});
// 创建mermaid渲染器
const renderMermaid = useMemo(
() => createMermaidRenderer(mermaidSuccessIdRef),
[],
);
// ==================== 渲染器自定义 ====================
/**
* 自定义 MarkdownIt 渲染器
*/
const customizeRenderer = useCallback(
(md: MarkdownIt) => {
const originalFenceRender = md.renderer.rules.fence;
// 自定义图片渲染
let imageCount = 0;
let htmlImageCount = 0; // HTML 标签图片计数
let mermaidCount = 0;
md.renderer.rules.image = (tokens, idx) => {
imageCount++;
const token = tokens[idx];
const src = getImagePath(token.attrGet('src') || '');
const alt = token.attrGet('alt') || token.content;
const rawAttrs = token.attrs || [];
// 过滤潜在危险属性(如 onload/onerror 等事件处理)
const safeAttrs = rawAttrs.filter(([name]) => {
const lower = name.toLowerCase();
// 屏蔽所有以 on 开头的属性,例如 onload/onerror/onclick 等
if (lower.startsWith('on')) return false;
return true;
});
return renderImage(src, alt, safeAttrs, imageCount - 1);
};
// 自定义代码块渲染
md.renderer.rules.fence = (tokens, idx, options, env, renderer) => {
const token = tokens[idx];
const info = token.info.trim();
const code = token.content;
if (info === 'mermaid') {
mermaidCount++;
return renderMermaid(code, mermaidCount);
}
const defaultRender = originalFenceRender || md.renderer.rules.fence;
const result = defaultRender
? defaultRender(tokens, idx, options, env, renderer)
: `<pre><code>${code}</code></pre>`;
return result;
};
// 处理行内代码
md.renderer.rules.code_inline = (tokens, idx) => {
const token = tokens[idx];
const code = token.content;
// 对行内代码内容做 HTML 转义,避免 `<svg onload=...>` 等被当成真正标签解析
const safeCode = md.utils.escapeHtml(code);
return `<code style="cursor: pointer;">${safeCode}</code>`;
};
// 自定义标题渲染h1 -> h2
md.renderer.rules.heading_open = (tokens, idx) => {
const token = tokens[idx];
if (token.tag === 'h1') {
token.tag = 'h2';
}
return `<${token.tag}>`;
};
md.renderer.rules.heading_close = (tokens, idx) => {
const token = tokens[idx];
return `</${token.tag}>`;
};
// 自定义链接渲染
md.renderer.rules.link_open = (tokens, idx) => {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
const href = hrefIndex >= 0 ? token.attrs![hrefIndex][1] : '';
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
return `<a href="${href}" target="_blank" rel="noopener noreferrer" style="color: ${theme.palette.primary.main}; text-decoration: underline;">`;
};
// 处理自定义 HTML 标签
const setupCustomHtmlHandlers = () => {
const originalHtmlBlock = md.renderer.rules.html_block;
const originalHtmlInline = md.renderer.rules.html_inline;
// HTML 白名单 - 只允许这些标签通过
const allowedTags = ['think', 'error'];
// 用于跟踪thinking状态
let isInThinking = false;
let thinkingContent = '';
// 检查是否是允许的标签
const isAllowedTag = (content: string): boolean => {
return allowedTags.some(
tag =>
content.includes(`<${tag}>`) || content.includes(`</${tag}>`),
);
};
// 解析 HTML img 标签并提取属性
const parseImgTag = (
html: string,
): {
src: string;
alt: string;
attrs: [string, string][];
} | null => {
// 匹配 <img> 标签(支持自闭合和普通标签)
const imgMatch = html.match(/<img\s+([^>]*?)\/?>/i);
if (!imgMatch) return null;
const attrsString = imgMatch[1];
const attrs: [string, string][] = [];
let src = '';
let alt = '';
// 解析属性:匹配 name="value" 或 name='value' 或 name=value
const attrRegex =
/([^\s=]+)(?:=["']([^"']*)["']|=(?:["'])?([^\s>]+)(?:["'])?)?/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
const name = attrMatch[1].toLowerCase();
const value = attrMatch[2] || attrMatch[3] || '';
// 过滤所有事件处理属性onload/onerror/onclick 等)
if (name.startsWith('on')) {
continue;
}
attrs.push([name, value]);
if (name === 'src') src = getImagePath(value);
if (name === 'alt') alt = value;
}
return { src, alt, attrs };
};
md.renderer.rules.html_block = (
tokens,
idx,
options,
env,
renderer,
) => {
const token = tokens[idx];
const content = token.content;
// 处理 think 标签开始
if (content.includes('<think>')) {
isInThinking = true;
thinkingContent = '';
return ''; // 不输出任何内容,开始收集
}
// 处理 think 标签结束
if (content.includes('</think>')) {
if (isInThinking) {
isInThinking = false;
const renderedThinking = renderThinking(thinkingContent.trim());
thinkingContent = '';
return renderedThinking;
}
return '';
}
// 如果在thinking标签内收集内容
if (isInThinking) {
thinkingContent += content;
return '';
}
// 处理 error 标签
if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content);
}
return originalHtmlBlock
? originalHtmlBlock(tokens, idx, options, env, renderer)
: content;
};
md.renderer.rules.html_inline = (
tokens,
idx,
options,
env,
renderer,
) => {
const token = tokens[idx];
const content = token.content;
if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content);
}
return originalHtmlInline
? originalHtmlInline(tokens, idx, options, env, renderer)
: content;
};
};
setupCustomHtmlHandlers();
},
[renderImage, renderMermaid, renderThinking, theme],
);
// ==================== Effects ====================
// 初始化 MarkdownIt
useEffect(() => {
if (!mdRef.current) {
mdRef.current = createMarkdownIt();
}
}, []);
// 主要的内容渲染 Effect
useEffect(() => {
if (!containerRef.current || !mdRef.current || !content) return;
// 处理 think 标签格式
const processedContent = processThinkingContent(content);
// 检查内容变化
if (processedContent === lastContentRef.current) return;
customizeRenderer(mdRef.current);
try {
// 渲染markdownthinking标签在renderer rules中直接处理
const newHtml = mdRef.current.render(processedContent);
incrementalRender(containerRef.current, newHtml, lastContentRef.current);
lastContentRef.current = processedContent;
scrollToBottom();
} catch (error) {
console.error('Markdown 渲染错误:', error);
if (containerRef.current) {
containerRef.current.innerHTML = '<div>Markdown 渲染错误</div>';
}
}
}, [content, customizeRenderer, scrollToBottom]);
// 添加代码块点击复制和图片点击预览功能(事件代理)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 检查是否点击了图片
const imgElement = target.closest(
'img.markdown-image',
) as HTMLImageElement;
if (imgElement) {
const originalSrc = imgElement.getAttribute('data-original-src');
if (originalSrc) {
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = imageBlobCacheRef.current.get(originalSrc);
setPreviewImgBlobUrl(blobUrl || originalSrc);
setPreviewOpen(true);
}
return;
}
// 检查是否点击了代码块
const preElement = target.closest('pre.hljs');
if (preElement) {
const codeElement = preElement.querySelector('code');
if (codeElement) {
const code = codeElement.textContent || '';
copyText(code.replace(/\n$/, ''));
}
return;
}
// 检查是否点击了行内代码
if (target.tagName === 'CODE' && !target.closest('pre')) {
const code = target.textContent || '';
copyText(code);
}
};
container.addEventListener('click', handleClick);
return () => {
clearImageBlobCache(imageBlobCacheRef.current);
container.removeEventListener('click', handleClick);
};
}, []);
// ==================== 组件样式 ====================
const componentStyles = {
fontSize: '14px',
background: 'transparent',
'--primary-color': theme.palette.primary.main,
'--background-paper': theme.palette.background.paper3,
// 省略号样式
'.three-ellipsis': {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
// 图片和 Mermaid 样式
'.image-container': {
position: 'relative',
display: 'inline-block',
},
'.markdown-image': {
cursor: 'pointer',
},
'.image-error': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100px',
color: '#999',
fontSize: '14px',
},
'.mermaid-loading': {
textAlign: 'center',
padding: '20px',
color: 'text.secondary',
fontSize: '14px',
},
// LaTeX 样式
'.katex': {
display: 'inline-block',
fontSize: '1em',
lineHeight: '1.2',
color: 'text.primary',
},
'.katex-display': {
textAlign: 'center',
margin: '1em 0',
overflow: 'auto',
'& > .katex': {
display: 'block',
fontSize: '1.1em',
color: 'text.primary',
},
},
// 暗色主题下的 LaTeX 样式
...(themeMode === 'dark' && {
'.katex, .katex *, .katex .mord, .katex .mrel, .katex .mop, .katex .mbin, .katex .mpunct, .katex .mopen, .katex .mclose, .katex-display':
{
color: `${theme.palette.text.primary} !important`,
},
}),
};
// ==================== 渲染 ====================
return (
<>
{/* 图片预览弹窗 */}
<Dialog
sx={{
'.MuiDialog-paper': {
maxWidth: '95vw',
maxHeight: '95vh',
},
}}
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setPreviewImgBlobUrl('');
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImgBlobUrl}
alt='preview'
style={{ width: '100%', height: '100%' }}
/>
</Dialog>
<Box
className={`markdown-body ${themeMode === 'dark' ? 'md-dark' : ''}`}
sx={componentStyles}
>
<div ref={containerRef} />
</Box>
</>
);
};
export default MarkDown2;

View File

@@ -0,0 +1,58 @@
'use client';
import mermaid from 'mermaid';
import React from 'react';
const MERMAID_CONFIG = {
startOnLoad: false,
theme: 'default' as const,
securityLevel: 'loose' as const,
fontFamily: 'inherit',
suppressErrorRendering: true,
};
// ==================== 全局状态 ====================
let isMermaidInitialized = false;
/**
* 初始化 Mermaid
*/
export const initializeMermaid = (): boolean => {
if (!isMermaidInitialized) {
try {
mermaid.initialize(MERMAID_CONFIG);
isMermaidInitialized = true;
return true;
} catch (error) {
console.error('Mermaid initialization error:', error);
return false;
}
}
return true;
};
/**
* 创建 Mermaid 渲染器
*/
export const createMermaidRenderer = (
mermaidSuccessIdRef: React.RefObject<Map<number, string>>,
) => {
return (code: string, mermaidCount: number): string => {
const svg = mermaidSuccessIdRef.current?.get(mermaidCount) || '';
const className = `mermaid-container-${mermaidCount}`;
setTimeout(async () => {
initializeMermaid();
try {
const id = `mermaid-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`;
const renderResult = await mermaid.render(id, code);
mermaidSuccessIdRef.current?.set(mermaidCount, renderResult.svg);
const mermaidContainer = document.querySelector(`.${className}`);
mermaidContainer!.innerHTML = renderResult.svg;
} catch (renderError) {}
});
return `<pre><div class="mermaid-container ${className}">${svg}</div></pre>`;
};
};

View File

@@ -0,0 +1,128 @@
'use client';
import { addOpacityToColor } from '@/utils';
import { IconButton, useTheme } from '@mui/material';
import { IconXiajiantou } from '@panda-wiki/icons';
import React, { useCallback, useRef } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
// ==================== 类型定义 ====================
interface ThinkingComponentProps {
content: string;
showThink: boolean;
onToggle: () => void;
loading?: boolean;
}
// ==================== Thinking 组件 ====================
const ThinkingComponent: React.FC<ThinkingComponentProps> = ({
content,
showThink,
onToggle,
loading = false,
}) => {
const theme = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className='think-content'
style={{
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
fontSize: '12px',
color: theme.palette.text.secondary,
marginBottom: '40px',
lineHeight: '20px',
backgroundColor: theme.palette.background.paper3,
padding: '16px',
cursor: 'pointer',
borderRadius: '10px',
}}
>
<div
className={`think-inner ${!showThink ? 'three-ellipsis' : ''}`}
style={{
transition: 'height 0.3s',
overflow: 'hidden',
height: showThink ? 'auto' : '60px',
}}
dangerouslySetInnerHTML={{ __html: content }}
/>
{!loading && (
<IconButton
size='small'
onClick={onToggle}
sx={{
bgcolor: 'background.paper3',
':hover': {
bgcolor: addOpacityToColor(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
},
}}
>
<IconXiajiantou
sx={{
fontSize: 18,
flexShrink: 0,
transition: 'transform 0.3s',
transform: showThink ? 'rotate(-180deg)' : 'rotate(0deg)',
}}
/>
</IconButton>
)}
</div>
);
};
// ==================== Thinking 渲染器 ====================
export interface ThinkingRendererOptions {
showThink: boolean;
onToggle: () => void;
loading?: boolean;
}
export const useThinkingRenderer = (options: ThinkingRendererOptions) => {
const { showThink, onToggle, loading } = options;
return useCallback(
(content: string) => {
const container = document.createElement('div');
const root = createRoot(container);
// 使用flushSync强制同步渲染
flushSync(() => {
root.render(
<ThinkingComponent
content={content}
showThink={showThink}
onToggle={onToggle}
loading={loading}
/>,
);
});
const html = container.innerHTML;
return html;
},
[showThink, onToggle, loading],
);
};
// ==================== 工具函数 ====================
/**
* 处理thinking标签的内容预处理
*/
export const processThinkingContent = (content: string): string => {
// 确保thinking标签格式正确
if (!content.includes('\n\n</think>')) {
const idx = content.indexOf('\n</think>');
if (idx !== -1) {
return content.slice(0, idx) + '\n\n</think>' + content.slice(idx + 9);
}
}
return content;
};

View File

@@ -0,0 +1,196 @@
import { Box, Popover, Stack, SxProps, Theme, Typography } from '@mui/material';
import React from 'react';
interface Item {
label: React.ReactNode;
icon?: React.ReactNode;
extra?: React.ReactNode;
selected?: boolean;
children?: Item[];
show?: boolean;
textSx?: SxProps<Theme>;
key: number | string;
onClick?: () => void;
}
interface MenuSelectProps {
id?: string;
arrowIcon?: React.ReactNode;
list: Item[];
context?: React.ReactElement<{ onClick?: any; 'aria-describedby'?: any }>;
anchorOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
transformOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
childrenProps?: {
anchorOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
transformOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
};
}
const MenuSelect: React.FC<MenuSelectProps> = ({
id = 'menu-select',
arrowIcon,
list,
context,
anchorOrigin = {
vertical: 'bottom',
horizontal: 'right',
},
transformOrigin = {
vertical: 'top',
horizontal: 'right',
},
childrenProps = {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
},
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null,
);
const [hoveredItem, setHoveredItem] = React.useState<Item | null>(null);
const [subMenuAnchor, setSubMenuAnchor] = React.useState<HTMLElement | null>(
null,
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
setHoveredItem(null);
setSubMenuAnchor(null);
};
const handleItemHover = (
event: React.MouseEvent<HTMLElement>,
item: Item,
) => {
if (item.children?.length) {
setHoveredItem(item);
setSubMenuAnchor(event.currentTarget);
}
};
const handleItemLeave = () => {
setHoveredItem(null);
setSubMenuAnchor(null);
};
const handleItemClick = (item: Item) => {
if (item.onClick) {
item.onClick();
}
handleClose();
};
const open = Boolean(anchorEl);
const curId = open ? id : undefined;
return (
<>
{context &&
React.cloneElement(context, {
onClick: handleClick,
'aria-describedby': curId,
})}
<Popover
id={curId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin}
>
<Box className='menu-select-list' sx={{ p: 0.5 }}>
{list.map(item =>
item.show === false ? null : (
<Box
className='menu-select-item'
key={item.key}
onMouseEnter={e => handleItemHover(e, item)}
onMouseLeave={handleItemLeave}
onClick={() => handleItemClick(item)}
sx={{
position: 'relative',
cursor: 'pointer',
}}
>
<Stack alignItems='center' gap={1} direction='row'>
{item.icon}
<Typography
variant='body1'
sx={{ flexShrink: 0, ...item.textSx }}
>
{item.label}
</Typography>
{item.extra}
{item.children?.length ? arrowIcon : null}
</Stack>
{hoveredItem === item && item.children && (
<Popover
open={Boolean(subMenuAnchor)}
anchorEl={subMenuAnchor}
onClose={handleItemLeave}
sx={{ pointerEvents: 'none' }}
{...childrenProps}
>
<Box
className='menu-select-sub-list'
sx={{
pointerEvents: 'auto',
p: 0.5,
}}
>
{item.children.map(child =>
child.show === false ? null : (
<Box
key={child.key}
className='menu-select-sub-item'
onClick={() => handleItemClick(child)}
sx={{
cursor: 'pointer',
}}
>
<Stack alignItems='center' gap={1} direction='row'>
{child.icon}
<Typography
sx={{ flexShrink: 0, ...child.textSx }}
>
{child.label}
</Typography>
{child.extra}
</Stack>
</Box>
),
)}
</Box>
</Popover>
)}
</Box>
),
)}
</Box>
</Popover>
</>
);
};
export default MenuSelect;

View File

@@ -0,0 +1,55 @@
'use client';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { Fab, Zoom } from '@mui/material';
import { useEffect, useState } from 'react';
interface ScrollToTopFabProps {
scrollContainerId?: string;
threshold?: number;
}
const ScrollToTopFab = ({
scrollContainerId = 'scroll-container',
threshold = 300,
}: ScrollToTopFabProps) => {
const [show, setShow] = useState(false);
const handleScroll = () => {
const container = document.getElementById(scrollContainerId);
setShow(container ? container.scrollTop > threshold : false);
};
const scrollToTop = () => {
const container = document.getElementById(scrollContainerId);
container?.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
const container = document.getElementById(scrollContainerId);
container?.addEventListener('scroll', handleScroll);
return () => container?.removeEventListener('scroll', handleScroll);
}, [scrollContainerId]);
return (
<Zoom in={show}>
<Fab
size='small'
onClick={scrollToTop}
sx={{
position: 'fixed',
bottom: 20,
right: 16,
zIndex: 10000,
backgroundColor: 'background.paper3',
color: 'text.primary',
'&:hover': { backgroundColor: 'background.paper2' },
}}
>
<KeyboardArrowUpIcon sx={{ fontSize: 24 }} />
</Fab>
</Zoom>
);
};
export default ScrollToTopFab;

View File

@@ -0,0 +1,38 @@
'use client';
import React from 'react';
import dayjs from 'dayjs';
import Watermark, { WatermarkProps } from './index';
import { useStore } from '@/provider';
import { ConstsWatermarkSetting } from '@/request/types';
const WaterMarkProvider = (props: WatermarkProps) => {
const { children, ...rest } = props;
const { kbDetail, authInfo } = useStore();
const content = kbDetail?.settings?.watermark_content;
const enable =
kbDetail?.settings?.watermark_setting !==
ConstsWatermarkSetting.WatermarkDisabled;
if (!enable) {
return children;
}
const time = `${authInfo?.username ?? ''} ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`;
const contentLines = [time, ...(content?.split('\n') || [])];
return (
<Watermark
{...rest}
content={contentLines}
opacity={
kbDetail?.settings?.watermark_setting ===
ConstsWatermarkSetting.WatermarkVisible
? 0.1
: 0.01
}
>
{children}
</Watermark>
);
};
export default WaterMarkProvider;

View File

@@ -0,0 +1,272 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { styled, Box, useTheme } from '@mui/material';
export type WatermarkProps = {
content?: string | string[];
fontSize?: number;
color?: string;
opacity?: number; // 0~1
mode?: 'visible' | 'invisible';
rotate?: number; // deg
gapX?: number; // 水印水平间距
gapY?: number; // 水印垂直间距
zIndex?: number;
fullPage?: boolean; // 是否铺满全页面
fontFamily?: string;
fontWeight?: number | string;
lineHeight?: number; // 行高倍数,仅对多行文本生效
offsetLeft?: number; // 背景平铺的起始左偏移
offsetTop?: number; // 背景平铺的起始上偏移
tileWidth?: number; // 单元格宽度(不传则自动根据文本与 gap 计算)
tileHeight?: number; // 单元格高度
pointerEvents?: 'auto' | 'none';
children?: React.ReactNode;
};
type GenerateOptions = {
contentLines: string[];
fontSize: number;
fontFamily: string;
fontWeight: number | string;
color: string;
opacity: number;
rotate: number;
gapX: number;
gapY: number;
lineHeight: number;
tileWidth?: number;
tileHeight?: number;
};
function generateWatermarkDataUrl(options: GenerateOptions): string | null {
if (typeof window === 'undefined') return null;
const {
contentLines,
fontSize,
fontFamily,
fontWeight,
color,
opacity,
rotate,
gapX,
gapY,
lineHeight,
tileWidth,
tileHeight,
} = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// 移除设备像素比处理,直接使用逻辑尺寸确保文字大小一致
const font = `${typeof fontWeight === 'number' ? fontWeight : fontWeight} ${fontSize}px ${fontFamily}`;
ctx.font = font;
// 估算文本最大宽度
let maxTextWidth = 0;
for (const line of contentLines) {
const metrics = ctx.measureText(line);
maxTextWidth = Math.max(maxTextWidth, metrics.width);
}
const textBlockHeight = contentLines.length * fontSize * lineHeight;
// 旋转后需要更大的画布,简单起见:给一定余量
const estimatedWidth = Math.ceil(maxTextWidth + gapX);
const estimatedHeight = Math.ceil(textBlockHeight + gapY);
const logicalWidth = tileWidth ?? estimatedWidth;
const logicalHeight = tileHeight ?? estimatedHeight;
// 直接使用逻辑尺寸,不乘以设备像素比
canvas.width = Math.max(1, logicalWidth);
canvas.height = Math.max(1, logicalHeight);
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
ctx.globalAlpha = opacity;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = font;
// 将原点移到单元格中心旋转后绘制,使平铺更自然
const centerX = logicalWidth / 2;
const centerY = logicalHeight / 2;
ctx.translate(centerX, centerY);
ctx.rotate((Math.PI / 180) * rotate);
const totalTextHeight = contentLines.length * fontSize * lineHeight;
const startY = -totalTextHeight / 2 + fontSize * 0.5;
for (let i = 0; i < contentLines.length; i += 1) {
const line = contentLines[i];
const y = startY + i * fontSize * lineHeight;
ctx.fillText(line, 0, y);
}
return canvas.toDataURL('image/png');
}
const StyledWatermarkOverlay = styled(Box)(() => ({
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}));
const StyledFullPageOverlay = styled(Box)(() => ({
position: 'fixed',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}));
const StyledContainer = styled(Box)(() => ({
position: 'relative',
height: '100%',
width: '100%',
}));
export default function Watermark(props: WatermarkProps) {
const theme = useTheme();
const {
content,
fontSize = 14,
color,
opacity,
rotate = -22,
gapX = 120,
gapY = 120,
zIndex = 9999,
fullPage = true,
fontFamily = 'sans-serif',
fontWeight = 'normal',
lineHeight = 1.2,
offsetLeft = 0,
offsetTop = 0,
tileWidth,
tileHeight,
pointerEvents = 'none',
children,
} = props;
// 解析 rgb/rgba/hex 颜色为 {r,g,b,a}
const parseColorToRgba = (
input: string,
): { r: number; g: number; b: number; a: number } | null => {
if (!input) return null;
const hexMatch = input.trim().match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (hexMatch) {
let hex = hexMatch[1];
if (hex.length === 3) {
hex = hex
.split('')
.map(c => c + c)
.join('');
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b, a: 1 };
}
const rgbaMatch = input.trim().match(/^rgba?\(([^)]+)\)$/i);
if (rgbaMatch) {
const parts = rgbaMatch[1].split(',').map(v => v.trim());
const r = parseInt(parts[0], 10);
const g = parseInt(parts[1], 10);
const b = parseInt(parts[2], 10);
const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
if ([r, g, b].some(v => Number.isNaN(v))) return null;
return { r, g, b, a: Number.isNaN(a) ? 1 : a };
}
return null;
};
const invertRgb = (r: number, g: number, b: number) => ({
r: 255 - r,
g: 255 - g,
b: 255 - b,
});
const resolvedColor = useMemo(() => {
if (color) return color;
if (typeof window === 'undefined') return theme.palette.text.disabled;
const rootEl = document.getElementById('app-theme-root');
if (!rootEl) return theme.palette.text.disabled;
const bg = getComputedStyle(rootEl).backgroundColor;
const rgba = parseColorToRgba(bg);
if (!rgba) return theme.palette.text.disabled;
const inv = invertRgb(rgba.r, rgba.g, rgba.b);
return `rgba(${inv.r}, ${inv.g}, ${inv.b}, 1)`;
}, [color, theme.palette.text.disabled]);
const contentLines = useMemo(
() => (Array.isArray(content) ? content : content ? [content] : []),
[content],
);
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
if (contentLines.length === 0) {
setDataUrl(null);
return;
}
const url = generateWatermarkDataUrl({
contentLines,
fontSize,
fontFamily,
fontWeight,
color: resolvedColor,
opacity: opacity ?? 0.1,
rotate,
gapX,
gapY,
lineHeight,
tileWidth,
tileHeight,
});
setDataUrl(url);
}, [
contentLines,
fontSize,
fontFamily,
fontWeight,
resolvedColor,
opacity,
rotate,
gapX,
gapY,
lineHeight,
tileWidth,
tileHeight,
]);
const backgroundStyles = useMemo(
() => ({
backgroundImage: dataUrl ? `url(${dataUrl})` : undefined,
backgroundRepeat: 'repeat' as const,
backgroundPosition: `${offsetLeft}px ${offsetTop}px`,
zIndex,
pointerEvents,
}),
[dataUrl, offsetLeft, offsetTop, zIndex, pointerEvents],
);
if (fullPage && !children) {
return <StyledFullPageOverlay sx={backgroundStyles} />;
}
return (
<StyledContainer>
{children}
<StyledWatermarkOverlay sx={backgroundStyles} />
</StyledContainer>
);
}

View File

@@ -0,0 +1,31 @@
export const VisitSceneWelcome = 1;
export const VisitSceneNode = 2;
export const VisitSceneChat = 3;
export const VisitSceneLogin = 4;
export const VisitSceneEnums = {
welcome: VisitSceneWelcome,
node: VisitSceneNode,
chat: VisitSceneChat,
login: VisitSceneLogin,
};
export const CONTENT_GAP = 96;
export const DOC_ANCHOR_WIDTH = 240;
export const NAV_BAR_HEIGHT = 44;
export const BASE_SCROLL_OFFSET = 80;
export const DocWidth = {
full: {
label: '全屏',
value: 0,
},
wide: {
label: '超宽',
value: 960,
},
normal: {
label: '常规',
value: 720,
},
};

View File

@@ -0,0 +1,7 @@
export { useCopy } from './useCopy';
export { useSmartScroll } from './useSmartScroll';
export type {
UseSmartScrollOptions,
UseSmartScrollReturn,
} from './useSmartScroll';
export { useBasePath } from './useBasePath';

View File

@@ -0,0 +1,8 @@
import { useStore } from '@/provider';
import { getBasePath } from '@/utils';
export const useBasePath = () => {
const { kbDetail } = useStore();
const url = kbDetail?.base_url;
if (!url) return '';
return getBasePath(url);
};

View File

@@ -0,0 +1,189 @@
'use client';
import { useCallback, useEffect } from 'react';
export type CopyControlMode = 'disable' | 'allow';
export interface UseCopyOptions {
mode?: CopyControlMode;
suffix?: string;
/**
* 绑定事件的目标容器。
* - 不传:默认绑定到 document
* - 传入元素:仅在该元素范围内拦截事件
*/
target?: Document | HTMLElement | null;
/**
* 当禁用复制时,是否同时禁用右键菜单(防止通过右键菜单复制)。默认 true
*/
blockContextMenuWhenDisabled?: boolean;
}
export interface UseCopyReturn {
/** 程序化复制文本(自动追加 suffix。禁用模式下返回 false */
copy: (text: string) => Promise<boolean>;
}
/**
* 控制复制行为的 Hook
* - mode: "disable" 全局(或目标内)禁用复制(含快捷键与右键菜单)
* - mode: "allow" 允许复制;如提供 suffix则在复制内容末尾追加
*/
export function useCopy(options: UseCopyOptions = {}): UseCopyReturn {
const {
mode = 'allow',
suffix,
target = typeof document !== 'undefined' ? document : null,
blockContextMenuWhenDisabled = true,
} = options;
useEffect(() => {
if (!target) return;
const onBeforeCopy = (e: Event) => {
if (mode === 'disable') {
e.preventDefault();
}
};
const onBeforeCut = (e: Event) => {
if (mode === 'disable') {
e.preventDefault();
}
};
const onCopy = (e: ClipboardEvent) => {
if (mode === 'disable') {
e.preventDefault();
return;
}
if (!suffix) return;
try {
// 读取当前选择文本
const selection =
typeof window !== 'undefined' && window.getSelection
? (window.getSelection()?.toString() ?? '')
: '';
const originalText =
selection || e.clipboardData?.getData('text/plain') || '';
const appended = originalText + suffix;
if (e.clipboardData) {
e.clipboardData.setData('text/plain', appended);
// 尝试同时更新 HTML简单处理尾缀作为纯文本追加
const originalHtml = e.clipboardData.getData('text/html');
if (originalHtml) {
const appendedHtml = originalHtml + suffix;
e.clipboardData.setData('text/html', appendedHtml);
}
// 阻止默认,让我们设置的内容生效
e.preventDefault();
}
} catch {}
};
const onCut = (e: ClipboardEvent) => {
if (mode === 'disable') {
e.preventDefault();
return;
}
if (!suffix) return;
try {
const selection =
typeof window !== 'undefined' && window.getSelection
? (window.getSelection()?.toString() ?? '')
: '';
const originalText =
selection || e.clipboardData?.getData('text/plain') || '';
const appended = originalText + suffix;
if (e.clipboardData) {
e.clipboardData.setData('text/plain', appended);
const originalHtml = e.clipboardData.getData('text/html');
if (originalHtml) {
const appendedHtml = originalHtml + suffix;
e.clipboardData.setData('text/html', appendedHtml);
}
e.preventDefault();
}
} catch {}
};
const onKeyDown = (e: KeyboardEvent) => {
if (mode !== 'disable') return;
const isCopyShortcut =
(e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C');
const isCutShortcut =
(e.metaKey || e.ctrlKey) && (e.key === 'x' || e.key === 'X');
const isCopyInsert = e.ctrlKey && e.key === 'Insert';
if (isCopyShortcut || isCutShortcut || isCopyInsert) {
e.preventDefault();
}
};
const onContextMenu = (e: MouseEvent) => {
if (mode === 'disable' && blockContextMenuWhenDisabled) {
e.preventDefault();
}
};
// 事件绑定(元素或 document
const add = (
name: string,
handler: EventListenerOrEventListenerObject,
opts?: boolean | AddEventListenerOptions,
) => {
(target as any).addEventListener(name, handler, opts);
};
const remove = (
name: string,
handler: EventListenerOrEventListenerObject,
opts?: boolean | EventListenerOptions,
) => {
(target as any).removeEventListener(name, handler, opts);
};
add('beforecopy', onBeforeCopy as EventListener, true);
add('beforecut', onBeforeCut as EventListener, true);
add('copy', onCopy as EventListener, true);
add('cut', onCut as EventListener, true);
add('keydown', onKeyDown as EventListener);
add('contextmenu', onContextMenu as EventListener, true);
return () => {
remove('beforecopy', onBeforeCopy as EventListener, true);
remove('beforecut', onBeforeCut as EventListener, true);
remove('copy', onCopy as EventListener, true);
remove('cut', onCut as EventListener, true);
remove('keydown', onKeyDown as EventListener);
remove('contextmenu', onContextMenu as EventListener, true);
};
}, [mode, suffix, target, blockContextMenuWhenDisabled]);
const copy = useCallback(
async (text: string) => {
if (mode === 'disable') return false;
const payload = suffix ? text + suffix : text;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(payload);
return true;
}
// 旧浏览器回退
const textarea = document.createElement('textarea');
textarea.value = payload;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
} catch {
return false;
}
},
[mode, suffix],
);
return { copy };
}
export default useCopy;

View File

@@ -0,0 +1,154 @@
import { TocItem, TocList } from '@ctzhian/tiptap';
import { useCallback, useEffect, useRef, useState } from 'react';
const useScroll = (headings: TocList, domId: string, defaultOffset = 80) => {
const [activeHeading, setActiveHeading] = useState<TocItem | null>(null);
const isFirstLoad = useRef(true);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isManualScroll = useRef(false);
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
) => {
return (...args: Parameters<T>) => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => func(...args), delay);
};
};
const scrollToElement = useCallback(
(elementId: string, offset = defaultOffset) => {
const element = document.getElementById(elementId);
if (element) {
const container = document.getElementById(domId) || window;
const targetHeading = headings.find(h => h.id === elementId);
if (targetHeading) {
isManualScroll.current = true;
setActiveHeading(targetHeading);
location.hash = encodeURIComponent(targetHeading.textContent);
const elementPosition = element.getBoundingClientRect().top;
const scrollTop =
'scrollY' in container ? container.scrollY : container.scrollTop;
const offsetPosition = elementPosition + scrollTop - offset;
container.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
setTimeout(() => {
isManualScroll.current = false;
}, 1000);
}
}
},
[headings, defaultOffset, domId],
);
const findActiveHeading = useCallback(() => {
const levels = Array.from(
new Set(headings.map(it => it.level).sort((a, b) => a - b)),
).slice(0, 3);
const visibleHeadings = headings.filter(header =>
levels.includes(header.level),
);
if (visibleHeadings.length === 0) return null;
const offset = 100;
let activeHeader: TocItem | null = null;
for (let i = visibleHeadings.length - 1; i >= 0; i--) {
const header = visibleHeadings[i];
const element = document.getElementById(header.id);
if (element) {
const container = document.getElementById(domId) || window;
const scrollTop =
'scrollY' in container ? container.scrollY : container.scrollTop;
const elementTop = element.getBoundingClientRect().top + scrollTop;
if (elementTop <= scrollTop + offset) {
activeHeader = header;
break;
}
}
}
if (!activeHeader && visibleHeadings.length > 0) {
activeHeader = visibleHeadings[0];
}
return activeHeader;
}, [headings]);
const debouncedScrollHandler = useCallback(
debounce(() => {
if (isManualScroll.current) return;
const activeHeader = findActiveHeading();
if (activeHeader && activeHeader.id !== activeHeading?.id) {
setActiveHeading(activeHeader);
}
}, 100),
[findActiveHeading, activeHeading],
);
useEffect(() => {
if (isFirstLoad.current && headings.length > 0) {
const hash = decodeURIComponent(location.hash).slice(1);
if (hash) {
const targetHeading = headings.find(
header => header.textContent === hash,
);
if (targetHeading) {
setActiveHeading(targetHeading);
setTimeout(() => {
isManualScroll.current = true;
const element = document.getElementById(targetHeading.id);
if (element) {
const container = document.getElementById(domId) || window;
const elementPosition = element.getBoundingClientRect().top;
const scrollTop =
'scrollY' in container
? container.scrollY
: container.scrollTop;
const offsetPosition =
elementPosition + scrollTop - defaultOffset;
container.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}
setTimeout(() => {
isManualScroll.current = false;
}, 1000);
}, 100);
}
}
isFirstLoad.current = false;
}
}, [headings, defaultOffset, domId]);
useEffect(() => {
if (headings.length === 0) return;
const container = document.getElementById(domId) || window;
container.addEventListener('scroll', debouncedScrollHandler);
debouncedScrollHandler();
return () => {
container.removeEventListener('scroll', debouncedScrollHandler);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [debouncedScrollHandler, headings, domId]);
return {
activeHeading,
scrollToElement,
};
};
export default useScroll;

View File

@@ -0,0 +1,369 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export interface UseSmartScrollOptions {
/**
* 容器的选择器(支持 CSS 选择器字符串或直接传入 HTMLElement
* @default '.conversation-container'
*/
container?: string | HTMLElement | (() => HTMLElement | null);
/**
* 距离底部的阈值(像素),在此范围内认为用户在底部
* @default 10
*/
threshold?: number;
/**
* 滚动行为
* @default 'smooth'
*/
behavior?: ScrollBehavior;
/**
* 是否启用智能滚动
* @default true
*/
enabled?: boolean;
/**
* 用户交互后恢复自动滚动的防抖时间(毫秒)
* @default 150
*/
resumeDebounceMs?: number;
}
export interface UseSmartScrollReturn {
/**
* 当前是否应该自动滚动
*/
shouldAutoScroll: boolean;
/**
* 滚动到底部(会根据 shouldAutoScroll 判断是否执行)
*/
scrollToBottom: () => void;
/**
* 强制滚动到底部(忽略 shouldAutoScroll 状态)
*/
forceScrollToBottom: () => void;
/**
* 手动设置是否应该自动滚动
*/
setShouldAutoScroll: (value: boolean) => void;
/**
* 检查当前是否在底部
*/
checkIfAtBottom: () => boolean;
/**
* 获取容器元素
*/
getContainer: () => HTMLElement | null;
}
/**
* 智能滚动 Hook
*/
export function useSmartScroll(
options: UseSmartScrollOptions = {},
): UseSmartScrollReturn {
const {
container = '.conversation-container',
threshold = 10,
behavior = 'smooth',
enabled = true,
resumeDebounceMs = 150,
} = options;
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
/**
* 使用 ref 存储同步的自动滚动标志,避免异步状态更新导致的竞争条件
*
* 场景说明:
* 1. SSE 流式输出内容,触发 scrollToBottom()
* 2. 用户向上滚动,触发用户交互事件
* 3. 交互事件调用 setShouldAutoScroll(false) - 这是异步的
* 4. 但在状态更新前,又有新的 SSE 内容到达,再次触发 scrollToBottom()
* 5. 此时 shouldAutoScroll 状态可能还是 true导致意外滚动
*
* 解决方案:
* - ref 的更新是同步的,用户交互事件会立即更新 ref
* - scrollToBottom() 检查 ref 而不是 state确保获取最新值
* - state 仍然保留,用于可能需要响应式更新的场景
*/
const shouldAutoScrollRef = useRef(true);
const containerRef = useRef<HTMLElement | null>(null);
const userInteractingRef = useRef(false); // 标记用户是否正在交互
const resumeTimerRef = useRef<NodeJS.Timeout | null>(null);
/**
* 获取容器元素
*/
const getContainer = useCallback((): HTMLElement | null => {
if (!enabled) return null;
// 如果已经缓存了容器,直接返回(前提是容器仍在 DOM 中)
if (containerRef.current && document.contains(containerRef.current)) {
return containerRef.current;
}
// 根据不同的 container 类型获取元素
let element: HTMLElement | null = null;
if (typeof container === 'string') {
element = document.querySelector<HTMLElement>(container);
} else if (typeof container === 'function') {
element = container();
} else if (container instanceof HTMLElement) {
element = container;
}
// 缓存容器引用
if (element) {
containerRef.current = element;
}
return element;
}, [container, enabled]);
/**
* 检查用户是否在底部(只读检查,不修改状态)
*/
const checkIfAtBottom = useCallback((): boolean => {
const element = getContainer();
if (!element) return false;
const isAtBottom =
element.scrollHeight - element.scrollTop - element.clientHeight <=
threshold;
return isAtBottom;
}, [getContainer, threshold]);
/**
* 处理滚轮事件 - 判断滚动方向
* 只有向上滚动且不在底部时才禁用自动滚动
*/
const handleWheel = useCallback(
(event: WheelEvent) => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// deltaY > 0 表示向下滚动,< 0 表示向上滚动
const isScrollingUp = event.deltaY < 0;
// 只有向上滚动且不在底部时才禁用自动滚动
if (isScrollingUp) {
userInteractingRef.current = true;
shouldAutoScrollRef.current = false;
setShouldAutoScroll(false);
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
},
[enabled, getContainer],
);
/**
* 处理触摸/点击事件 - 任何触摸或点击滚动条都视为用户主动操作
*/
const handleUserInteraction = useCallback(() => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// 检查是否在底部阈值内
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
// 如果不在底部,才禁用自动滚动
if (!isAtBottom) {
userInteractingRef.current = true;
shouldAutoScrollRef.current = false;
setShouldAutoScroll(false);
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
}, [enabled, threshold, getContainer]);
/**
* 处理滚动事件
* 仅用于检测用户是否滚动到底部,以便恢复自动滚动
*/
const handleScrollEvent = useCallback(() => {
if (!enabled) return;
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
// 使用防抖检查是否在底部
resumeTimerRef.current = setTimeout(() => {
const element = getContainer();
if (!element) return;
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
// 如果用户滚动到底部,恢复自动滚动
if (isAtBottom && !shouldAutoScrollRef.current) {
userInteractingRef.current = false;
shouldAutoScrollRef.current = true;
setShouldAutoScroll(true);
}
}, resumeDebounceMs);
}, [enabled, threshold, resumeDebounceMs, getContainer]);
/**
* 强制滚动到底部(忽略 shouldAutoScroll 状态,并重置为允许自动滚动)
*/
const forceScrollToBottom = useCallback(() => {
if (!enabled) return;
const element = getContainer();
if (element) {
// 强制滚动时,重置为允许自动滚动状态
userInteractingRef.current = false;
shouldAutoScrollRef.current = true;
setShouldAutoScroll(true);
// 清除恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
element.scrollTo({
top: element.scrollHeight,
behavior,
});
}
}, [getContainer, behavior, enabled]);
/**
* 滚动到底部(会根据 shouldAutoScroll 判断)
* 注意:这里使用 ref 而不是 state确保检查的是最新的同步值
*/
const scrollToBottom = useCallback(() => {
if (!shouldAutoScrollRef.current || !enabled) return;
forceScrollToBottom();
}, [forceScrollToBottom, enabled]);
/**
* 监听用户交互事件和滚动事件
*/
useEffect(() => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// 监听用户交互事件(表明用户主动操作)
element.addEventListener('wheel', handleWheel as EventListener, {
passive: true,
});
element.addEventListener('touchstart', handleUserInteraction, {
passive: true,
});
// 监听滚动事件(用于检测是否回到底部)
element.addEventListener('scroll', handleScrollEvent, { passive: true });
return () => {
element.removeEventListener('wheel', handleWheel as EventListener);
element.removeEventListener('touchstart', handleUserInteraction);
element.removeEventListener('scroll', handleScrollEvent);
// 清理恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
};
}, [
getContainer,
handleScrollEvent,
handleWheel,
handleUserInteraction,
enabled,
]);
/**
* 监听容器内容高度变化(使用 ResizeObserver
* 当内容高度增加且允许自动滚动时,自动滚动到底部
*/
useEffect(() => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// 获取滚动容器的第一个子元素(实际包含内容的元素)
const contentElement = element.firstElementChild as HTMLElement;
if (!contentElement) return;
// 使用 ResizeObserver 监听内容元素的尺寸变化
const resizeObserver = new ResizeObserver(() => {
// 只有在允许自动滚动时才触发
if (shouldAutoScrollRef.current) {
// 使用 requestAnimationFrame 确保在 DOM 更新后滚动
requestAnimationFrame(() => {
scrollToBottom();
});
}
});
resizeObserver.observe(contentElement);
return () => {
resizeObserver.disconnect();
};
}, [enabled, getContainer, scrollToBottom]);
/**
* 手动设置是否应该自动滚动(包装函数,同时更新 state 和 ref
*/
const setShouldAutoScrollWrapper = useCallback((value: boolean) => {
shouldAutoScrollRef.current = value;
setShouldAutoScroll(value);
// 如果设置为 true重置用户交互状态
if (value) {
userInteractingRef.current = false;
// 清除恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
}, []);
return {
shouldAutoScroll,
scrollToBottom,
forceScrollToBottom,
setShouldAutoScroll: setShouldAutoScrollWrapper,
checkIfAtBottom,
getContainer,
};
}

View File

@@ -0,0 +1,18 @@
import { findNavIdByNodeId } from '@/utils/tree';
import { useStore } from '@/provider';
import { useParams } from 'next/navigation';
import { useEffect } from 'react';
export function useSyncNavByDocId() {
const params = useParams();
const docId = params?.id as string | undefined;
const { navDataMap = {}, selectedNavId, setSelectedNavId } = useStore();
useEffect(() => {
if (!docId || !setSelectedNavId) return;
const navId = findNavIdByNodeId(navDataMap, docId);
if (navId !== undefined && navId !== selectedNavId) {
setSelectedNavId(navId);
}
}, [docId, navDataMap, selectedNavId, setSelectedNavId]);
}

View File

@@ -0,0 +1,34 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
// 只在生产环境下启用 Sentry
if (process.env.NODE_ENV === 'production') {
Sentry.init({
dsn: 'https://88c396fc9b383382005465cfc9120e5d@sentry.baizhi.cloud/5',
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}
// 只在生产环境下导出路由转换捕获函数
export const onRouterTransitionStart =
process.env.NODE_ENV === 'production'
? Sentry.captureRouterTransitionStart
: undefined;

View File

@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
// 只在生产环境下启用 Sentry
if (process.env.NODE_ENV === 'production') {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('../sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config');
}
}
}
// 只在生产环境下导出错误捕获函数
export const onRequestError =
process.env.NODE_ENV === 'production'
? Sentry.captureRequestError
: undefined;

View File

@@ -0,0 +1,204 @@
'use client';
import { ITreeItem, KBDetail, NodeListItem, WidgetInfo } from '@/assets/type';
import { useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
Dispatch,
SetStateAction,
} from 'react';
import { useParams } from 'next/navigation';
import { GithubComChaitinPandaWikiProApiShareV1AuthInfoResp } from '@/request/pro/types';
import {
filterEmptyFolders,
convertToTree,
findNavIdByNodeId,
addExpandState,
type NavItem,
} from '@/utils/tree';
interface StoreContextType {
authInfo?: GithubComChaitinPandaWikiProApiShareV1AuthInfoResp;
widget?: WidgetInfo;
kbDetail?: KBDetail;
catalogShow?: boolean;
tree?: ITreeItem[];
themeMode?: 'light' | 'dark';
mobile?: boolean;
nodeList?: NodeListItem[];
setNodeList?: (list: NodeListItem[]) => void;
setTree?: Dispatch<SetStateAction<ITreeItem[] | undefined>>;
setCatalogShow?: (value: boolean) => void;
catalogWidth?: number;
setCatalogWidth?: (value: number) => void;
qaModalOpen?: boolean;
setQaModalOpen?: (value: boolean) => void;
/** 栏目列表,多栏目时展示导航栏 */
navList?: NavItem[];
/** 当前选中的栏目 id */
selectedNavId?: string;
setSelectedNavId?: Dispatch<SetStateAction<string | undefined>>;
/** 各栏目对应的文档列表 nav_id -> NodeListItem[] */
navDataMap?: Record<string, NodeListItem[]>;
}
export const StoreContext = createContext<StoreContextType | undefined>(
undefined,
);
export const useStore = () => {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useStore must be used within a StoreProvider');
}
return context;
};
export default function StoreProvider({
children,
...props
}: StoreContextType & { children: React.ReactNode }) {
const context = useContext(StoreContext) || {};
const {
widget = context.widget,
kbDetail = context.kbDetail,
themeMode = context.themeMode,
nodeList: initialNodeList = context.nodeList || [],
mobile = context.mobile,
authInfo = context.authInfo,
tree: initialTree = context.tree || [],
navList: initialNavList = context.navList || [],
selectedNavId: initialSelectedNavId = context.selectedNavId,
navDataMap: initialNavDataMap = context.navDataMap || {},
} = props;
const NAV_ID_STORAGE_KEY = 'panda-wiki-selected-nav-id';
// 使用 props 传入的 defaultNavId避免 SSR 与 CSR 不一致导致 Hydration 错误
const initialNavId = initialSelectedNavId;
const catalogSettings = kbDetail?.settings?.catalog_settings;
const [catalogWidth, setCatalogWidth] = useState<number>(() => {
return catalogSettings?.catalog_width || 260;
});
const [nodeList, setNodeList] = useState<NodeListItem[] | undefined>(
initialNodeList,
);
const [tree, setTree] = useState<ITreeItem[] | undefined>(() => {
if (
initialNavId !== undefined &&
initialNavId !== '' &&
initialNavDataMap[initialNavId]
) {
return filterEmptyFolders(convertToTree(initialNavDataMap[initialNavId]));
}
return initialTree;
});
const [qaModalOpen, setQaModalOpen] = useState(false);
const [navList] = useState<NavItem[]>(initialNavList);
const [navDataMap] =
useState<Record<string, NodeListItem[]>>(initialNavDataMap);
const [selectedNavId, setSelectedNavIdState] = useState<string | undefined>(
initialNavId,
);
const setSelectedNavId: Dispatch<
SetStateAction<string | undefined>
> = value => {
setSelectedNavIdState(prev => {
const next = typeof value === 'function' ? value(prev) : value;
if (typeof window !== 'undefined' && next) {
localStorage.setItem(NAV_ID_STORAGE_KEY, next);
}
return next;
});
};
const [catalogShow, setCatalogShow] = useState(
catalogSettings?.catalog_visible !== 2,
);
const [isMobile, setIsMobile] = useState(mobile);
const theme = useTheme();
const mediaQueryResult = useMediaQuery(theme.breakpoints.down('lg'), {
noSsr: true,
});
useEffect(() => {
if (kbDetail) {
setCatalogShow(catalogSettings?.catalog_visible !== 2);
}
}, [kbDetail]);
useEffect(() => {
const savedWidth = window.localStorage.getItem('CATALOG_WIDTH');
if (Number(savedWidth) > 0) {
setCatalogWidth(Number(savedWidth));
}
}, []);
useEffect(() => {
setIsMobile(mediaQueryResult);
}, [mediaQueryResult]);
const params = useParams();
const docId = (params?.id as string) || undefined;
const catalogFolderExpand = catalogSettings?.catalog_folder !== 2;
useEffect(() => {
if (
navDataMap &&
selectedNavId !== undefined &&
selectedNavId !== '' &&
navDataMap[selectedNavId]
) {
const nodeList = navDataMap[selectedNavId];
let newTree = filterEmptyFolders(convertToTree(nodeList));
if (docId) {
const { tree: expandedTree } = addExpandState(
newTree,
docId,
catalogFolderExpand,
);
newTree = expandedTree;
}
setTree(newTree);
}
}, [selectedNavId, navDataMap, docId, catalogFolderExpand]);
return (
<StoreContext.Provider
value={{
widget,
kbDetail,
themeMode,
nodeList,
catalogShow,
setCatalogShow,
mobile: isMobile,
authInfo,
setNodeList,
catalogWidth,
tree,
setTree,
setCatalogWidth: value => {
setCatalogWidth(value);
window.localStorage.setItem('CATALOG_WIDTH', value.toString());
},
qaModalOpen,
setQaModalOpen,
navList,
selectedNavId,
setSelectedNavId,
navDataMap,
}}
>
{children}
</StoreContext.Provider>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { darkTheme, lightTheme } from '@/theme';
import { ThemeProvider } from '@ctzhian/ui';
import { createTheme } from '@mui/material';
import Cookies from 'js-cookie';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
const ThemeContext = createContext<{
themeMode: 'light' | 'dark';
setThemeMode: (themeMode: 'light' | 'dark') => void;
}>({
themeMode: 'light',
setThemeMode: () => {},
});
export const useThemeStore = () => {
return useContext(ThemeContext);
};
export const ThemeStoreProvider = ({
children,
themeMode: initialThemeMode,
}: {
themeMode: 'light' | 'dark';
children: React.ReactNode;
}) => {
const [themeMode, setThemeMode] = useState<'light' | 'dark'>(
initialThemeMode,
);
const theme = useMemo(() => {
return createTheme(themeMode === 'dark' ? darkTheme : lightTheme);
}, [themeMode]);
useEffect(() => {
Cookies.set('theme_mode', themeMode, { expires: 365 * 10 });
}, [themeMode]);
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ThemeContext.Provider>
);
};

228
web/app/src/proxy.ts Normal file
View File

@@ -0,0 +1,228 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { getShareV1AppWidgetInfo } from './request/ShareApp';
import { getBasePath, parsePathname } from '@/utils';
import { postShareV1StatPage } from '@/request/ShareStat';
import { getShareV1NodeList } from '@/request/ShareNode';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import {
filterEmptyFolders,
convertToTree,
parseNodeListResponse,
} from '@/utils/tree';
import { deepSearchFirstNode } from '@/utils';
const StatPage = {
welcome: 1,
node: 2,
chat: 3,
auth: 4,
} as const;
const getFirstNode = async () => {
const nodeListResult: any = await getShareV1NodeList();
const { isGrouped, navDataMap, defaultNavId } = parseNodeListResponse(
nodeListResult || [],
);
const nodeListForTree = isGrouped
? (navDataMap[defaultNavId || ''] ?? navDataMap[Object.keys(navDataMap)[0]])
: nodeListResult || [];
const tree = filterEmptyFolders(
convertToTree(Array.isArray(nodeListForTree) ? nodeListForTree : []),
);
return deepSearchFirstNode(tree);
};
const getHomePath = async () => {
const info = await getShareV1AppWebInfo();
return info?.settings?.home_page_setting;
};
const stripBasePath = (pathname: string, basePath: string) => {
if (!basePath) return pathname;
if (pathname === basePath) return '/';
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length) || '/';
}
return pathname;
};
const homeProxy = async (
request: NextRequest,
headers: Record<string, string>,
session: string,
pathname?: string,
) => {
const url = request.nextUrl.clone();
if (pathname) {
url.pathname = pathname;
}
const { page, id } = parsePathname(url.pathname);
try {
// 获取节点列表
if (url.pathname === '/') {
const homePath = await getHomePath();
if (homePath === 'custom') {
return NextResponse.rewrite(new URL('/home', request.url));
} else {
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
if (firstNode) {
return NextResponse.rewrite(
new URL(`/node/${firstNode.id}`, request.url),
);
}
return NextResponse.rewrite(new URL('/node', request.url));
}
}
// 页面上报
const pages = Object.keys(StatPage);
if (pages.includes(page) || pages.includes(id)) {
postShareV1StatPage(
{
scene: StatPage[page as keyof typeof StatPage],
node_id: id || '',
},
{
headers: {
'x-pw-session-id': session,
...headers,
},
},
);
}
if (pathname && pathname !== request.nextUrl.pathname) {
return NextResponse.rewrite(
new URL(`${url.pathname}${url.search}`, request.url),
);
}
return NextResponse.next();
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
error.message === 'NEXT_REDIRECT'
) {
return NextResponse.redirect(
new URL(
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
request.url,
),
);
}
}
if (pathname && pathname !== request.nextUrl.pathname) {
return NextResponse.rewrite(
new URL(`${url.pathname}${url.search}`, request.url),
);
}
return NextResponse.next();
};
const proxyShare = async (request: NextRequest, pathname?: string) => {
// 转发到 process.env.TARGET
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
const targetOrigin = process.env.TARGET!;
const targetUrl = new URL(
(pathname || request.nextUrl.pathname) + request.nextUrl.search,
targetOrigin,
);
// 构造 fetch 选项
const fetchHeaders = new Headers(request.headers);
fetchHeaders.set('x-kb-id', kb_id);
const hasBody = !['GET', 'HEAD'].includes(request.method);
const fetchOptions: RequestInit = {
method: request.method,
headers: fetchHeaders,
body: hasBody ? request.body : undefined,
redirect: 'manual',
...(hasBody && { duplex: 'half' as const }),
};
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
const nextRes = new NextResponse(proxyRes.body, {
status: proxyRes.status,
headers: proxyRes.headers,
statusText: proxyRes.statusText,
});
return nextRes;
};
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const pathname = url.pathname;
const kbDetail = await getShareV1AppWebInfo();
const basePath = getBasePath(kbDetail?.base_url || '');
const appPathname = stripBasePath(pathname, basePath);
if (appPathname.startsWith('/widget')) {
const widgetInfo: any = await getShareV1AppWidgetInfo();
if (widgetInfo) {
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
}
return;
}
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
let needSetSessionId = false;
if (!sessionId) {
sessionId = uuidv4();
needSetSessionId = true;
}
let response: NextResponse;
if (appPathname.startsWith('/share/')) {
response = await proxyShare(request, appPathname);
} else {
response = await homeProxy(request, headers, sessionId, appPathname);
}
if (needSetSessionId) {
response.cookies.set('x-pw-session-id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 年
});
}
if (!appPathname.startsWith('/share')) {
response.headers.set('x-current-path', appPathname);
response.headers.set('x-current-search', url.search);
}
return response;
}
export const config = {
matcher: [
'/',
'/home',
'/:basePath/home',
'/share/:path*',
'/:basePath/share/:path*',
'/chat/:path*',
'/:basePath/chat/:path*',
'/widget',
'/:basePath/widget',
'/welcome',
'/:basePath/welcome',
'/auth/login',
'/:basePath/auth/login',
'/node/:path*',
'/:basePath/node/:path*',
'/node',
'/:basePath/node',
],
};

View File

@@ -0,0 +1,59 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import { DomainAppInfoResp, DomainResponse } from "./types";
/**
* @description GetAppInfo
*
* @tags share_app
* @name GetShareV1AppWebInfo
* @summary GetAppInfo
* @request GET:/share/v1/app/web/info
* @response `200` `(DomainResponse & {
data?: DomainAppInfoResp,
})` OK
*/
export const getShareV1AppWebInfo = (params: RequestParams = {}) =>
httpRequest<
DomainResponse & {
data?: DomainAppInfoResp;
}
>({
path: `/share/v1/app/web/info`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description GetWidgetAppInfo
*
* @tags share_app
* @name GetShareV1AppWidgetInfo
* @summary GetWidgetAppInfo
* @request GET:/share/v1/app/widget/info
* @response `200` `DomainResponse` OK
*/
export const getShareV1AppWidgetInfo = (params: RequestParams = {}) =>
httpRequest<DomainResponse>({
path: `/share/v1/app/widget/info`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,100 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainPWResponse,
DomainResponse,
GithubComChaitinPandaWikiApiShareV1AuthGetResp,
V1AuthGitHubReq,
V1AuthGitHubResp,
V1AuthLoginSimpleReq,
} from "./types";
/**
* @description AuthGet
*
* @tags share_auth
* @name GetShareV1AuthGet
* @summary AuthGet
* @request GET:/share/v1/auth/get
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiApiShareV1AuthGetResp,
})` OK
*/
export const getShareV1AuthGet = (params: RequestParams = {}) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiApiShareV1AuthGetResp;
}
>({
path: `/share/v1/auth/get`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description GitHub登录
*
* @tags ShareAuth
* @name PostShareV1AuthGithub
* @summary GitHub登录
* @request POST:/share/v1/auth/github
* @response `200` `(DomainPWResponse & {
data?: V1AuthGitHubResp,
})` OK
*/
export const postShareV1AuthGithub = (
param: V1AuthGitHubReq,
params: RequestParams = {},
) =>
httpRequest<
DomainPWResponse & {
data?: V1AuthGitHubResp;
}
>({
path: `/share/v1/auth/github`,
method: "POST",
body: param,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description AuthLoginSimple
*
* @tags share_auth
* @name PostShareV1AuthLoginSimple
* @summary AuthLoginSimple
* @request POST:/share/v1/auth/login/simple
* @response `200` `DomainResponse` OK
*/
export const postShareV1AuthLoginSimple = (
param: V1AuthLoginSimpleReq,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/auth/login/simple`,
method: "POST",
body: param,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
ConstsRedeemCaptchaReq,
GocapChallengeData,
GocapVerificationResult,
} from "./types";
/**
* @description CreateCaptcha
*
* @tags share_captcha
* @name PostShareV1CaptchaChallenge
* @summary CreateCaptcha
* @request POST:/share/v1/captcha/challenge
* @response `200` `GocapChallengeData` OK
*/
export const postShareV1CaptchaChallenge = (params: RequestParams = {}) =>
httpRequest<GocapChallengeData>({
path: `/share/v1/captcha/challenge`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description RedeemCaptcha
*
* @tags share_captcha
* @name PostShareV1CaptchaRedeem
* @summary RedeemCaptcha
* @request POST:/share/v1/captcha/redeem
* @response `200` `GocapVerificationResult` OK
*/
export const postShareV1CaptchaRedeem = (
body: ConstsRedeemCaptchaReq,
params: RequestParams = {},
) =>
httpRequest<GocapVerificationResult>({
path: `/share/v1/captcha/redeem`,
method: "POST",
body: body,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,120 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainChatRequest,
DomainFeedbackRequest,
DomainOpenAICompletionsRequest,
DomainOpenAICompletionsResponse,
DomainResponse,
PostShareV1ChatMessageParams,
V1WechatAppInfoResp,
} from "./types";
/**
* @description WechatAppInfo
*
* @tags share_chat
* @name GetShareV1AppWechatInfo
* @summary WechatAppInfo
* @request GET:/share/v1/app/wechat/info
* @response `200` `(DomainResponse & {
data?: V1WechatAppInfoResp,
})` OK
*/
export const getShareV1AppWechatInfo = (params: RequestParams = {}) =>
httpRequest<
DomainResponse & {
data?: V1WechatAppInfoResp;
}
>({
path: `/share/v1/app/wechat/info`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description OpenAI API compatible chat completions endpoint
*
* @tags share_chat
* @name PostShareV1ChatCompletions
* @summary ChatCompletions
* @request POST:/share/v1/chat/completions
* @response `200` `DomainOpenAICompletionsResponse` OK
* @response `400` `DomainOpenAIErrorResponse` Bad Request
*/
export const postShareV1ChatCompletions = (
request: DomainOpenAICompletionsRequest,
params: RequestParams = {},
) =>
httpRequest<DomainOpenAICompletionsResponse>({
path: `/share/v1/chat/completions`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description Process user feedback for chat conversations
*
* @tags share_chat
* @name PostShareV1ChatFeedback
* @summary Handle chat feedback
* @request POST:/share/v1/chat/feedback
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatFeedback = (
request: DomainFeedbackRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/feedback`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description ChatMessage
*
* @tags share_chat
* @name PostShareV1ChatMessage
* @summary ChatMessage
* @request POST:/share/v1/chat/message
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatMessage = (
query: PostShareV1ChatMessageParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/message`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,48 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainChatSearchReq,
DomainChatSearchResp,
DomainResponse,
} from "./types";
/**
* @description ChatSearch
*
* @tags share_chat_search
* @name PostShareV1ChatSearch
* @summary ChatSearch
* @request POST:/share/v1/chat/search
* @response `200` `(DomainResponse & {
data?: DomainChatSearchResp,
})` OK
*/
export const postShareV1ChatSearch = (
request: DomainChatSearchReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: DomainChatSearchResp;
}
>({
path: `/share/v1/chat/search`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,79 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainCommentReq,
DomainPWResponse,
GetShareV1CommentListParams,
ShareShareCommentLists,
} from "./types";
/**
* @description CreateComment
*
* @tags share_comment
* @name PostShareV1Comment
* @summary CreateComment
* @request POST:/share/v1/comment
* @response `200` `(DomainPWResponse & {
data?: string,
})` CommentID
*/
export const postShareV1Comment = (
comment: DomainCommentReq,
params: RequestParams = {},
) =>
httpRequest<
DomainPWResponse & {
data?: string;
}
>({
path: `/share/v1/comment`,
method: "POST",
body: comment,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description GetCommentList
*
* @tags share_comment
* @name GetShareV1CommentList
* @summary GetCommentList
* @request GET:/share/v1/comment/list
* @response `200` `(DomainPWResponse & {
data?: ShareShareCommentLists,
})` CommentList
*/
export const getShareV1CommentList = (
query: GetShareV1CommentListParams,
params: RequestParams = {},
) =>
httpRequest<
DomainPWResponse & {
data?: ShareShareCommentLists;
}
>({
path: `/share/v1/comment/list`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,48 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainPWResponse,
DomainShareConversationDetailResp,
GetShareV1ConversationDetailParams,
} from "./types";
/**
* @description GetConversationDetail
*
* @tags share_conversation
* @name GetShareV1ConversationDetail
* @summary GetConversationDetail
* @request GET:/share/v1/conversation/detail
* @response `200` `(DomainPWResponse & {
data?: DomainShareConversationDetailResp,
})` OK
*/
export const getShareV1ConversationDetail = (
query: GetShareV1ConversationDetailParams,
params: RequestParams = {},
) =>
httpRequest<
DomainPWResponse & {
data?: DomainShareConversationDetailResp;
}
>({
path: `/share/v1/conversation/detail`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,80 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainResponse,
PostShareV1CommonFileUploadPayload,
V1FileUploadResp,
V1ShareFileUploadUrlReq,
V1ShareFileUploadUrlResp,
} from "./types";
/**
* @description 前台用户上传文件,目前只支持图片文件上传
*
* @tags ShareFile
* @name PostShareV1CommonFileUpload
* @summary 文件上传
* @request POST:/share/v1/common/file/upload
* @response `200` `(DomainResponse & {
data?: V1FileUploadResp,
})` OK
*/
export const postShareV1CommonFileUpload = (
data: PostShareV1CommonFileUploadPayload,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: V1FileUploadResp;
}
>({
path: `/share/v1/common/file/upload`,
method: "POST",
body: data,
type: ContentType.FormData,
format: "json",
...params,
});
/**
* @description 前台用户上传文件,目前只支持图片文件上传
*
* @tags ShareFile
* @name PostShareV1CommonFileUploadUrl
* @summary 文件上传
* @request POST:/share/v1/common/file/upload/url
* @response `200` `(DomainResponse & {
data?: V1ShareFileUploadUrlResp,
})` OK
*/
export const postShareV1CommonFileUploadUrl = (
body: V1ShareFileUploadUrlReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: V1ShareFileUploadUrlResp;
}
>({
path: `/share/v1/common/file/upload/url`,
method: "POST",
body: body,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import { DomainResponse, GetShareV1NavListParams } from "./types";
/**
* @description ShareNavList
*
* @tags share_nav
* @name GetShareV1NavList
* @summary 前台获取栏目列表
* @request GET:/share/v1/nav/list
* @response `200` `DomainResponse` OK
*/
export const getShareV1NavList = (
query: GetShareV1NavListParams,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/nav/list`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});

View File

@@ -0,0 +1,67 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainResponse,
GetShareV1NodeDetailParams,
V1ShareNodeDetailResp,
} from "./types";
/**
* @description GetNodeDetail
*
* @tags share_node
* @name GetShareV1NodeDetail
* @summary GetNodeDetail
* @request GET:/share/v1/node/detail
* @response `200` `(DomainResponse & {
data?: V1ShareNodeDetailResp,
})` OK
*/
export const getShareV1NodeDetail = (
query: GetShareV1NodeDetailParams,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: V1ShareNodeDetailResp;
}
>({
path: `/share/v1/node/detail`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description ShareNodeList
*
* @tags share_node
* @name GetShareV1NodeList
* @summary ShareNodeList
* @request GET:/share/v1/node/list
* @response `200` `DomainResponse` OK
*/
export const getShareV1NodeList = (params: RequestParams = {}) =>
httpRequest<DomainResponse>({
path: `/share/v1/node/list`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});

Some files were not shown because too many files have changed in this diff Show More