init push
5
web/app/src/app/(pages)/(doc)/editor/[[...id]]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DocEditor from '@/views/editor';
|
||||
|
||||
export default function EditorPage() {
|
||||
return <DocEditor />;
|
||||
}
|
||||
84
web/app/src/app/(pages)/(doc)/home/page.tsx
Normal 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;
|
||||
7
web/app/src/app/(pages)/(doc)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <WaterMarkProvider>{children}</WaterMarkProvider>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
118
web/app/src/app/(pages)/(doc)/node/NodeClientLayout.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
web/app/src/app/(pages)/(doc)/node/[id]/page.tsx
Normal 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;
|
||||
5
web/app/src/app/(pages)/(doc)/node/error.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import ErrorComponent from '@/components/error';
|
||||
|
||||
export default ErrorComponent;
|
||||
47
web/app/src/app/(pages)/(doc)/node/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
web/app/src/app/(pages)/(doc)/node/page.tsx
Normal 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;
|
||||
3
web/app/src/app/(pages)/(doc)/welcome/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HomePage from '../home/page';
|
||||
|
||||
export default HomePage;
|
||||
7
web/app/src/app/(pages)/auth/login/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Login from '@/views/auth/login';
|
||||
|
||||
const LoginPage = async () => {
|
||||
return <Login />;
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
38
web/app/src/app/(pages)/layout.tsx
Normal 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;
|
||||
48
web/app/src/app/(pages)/not-found.tsx
Normal 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
@@ -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
|
After Width: | Height: | Size: 78 KiB |
19
web/app/src/app/feedback/layout.tsx
Normal 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;
|
||||
17
web/app/src/app/feedback/page.tsx
Normal 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;
|
||||
78
web/app/src/app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
web/app/src/app/globals.css
Normal 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;
|
||||
}
|
||||
3
web/app/src/app/h5-chat/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import H5Chat from '@/views/h5Chat';
|
||||
|
||||
export default H5Chat;
|
||||
134
web/app/src/app/layout.tsx
Normal 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
48
web/app/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
web/app/src/app/widget/layout.tsx
Normal 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;
|
||||
3
web/app/src/app/widget/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Widget from '@/views/widget';
|
||||
|
||||
export default Widget;
|
||||
BIN
web/app/src/assets/fonts/AlibabaPuHuiTi-Bold.ttf
Normal file
BIN
web/app/src/assets/fonts/AlibabaPuHuiTi-Regular.ttf
Normal file
BIN
web/app/src/assets/fonts/gilroy-bold-700.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-light-300.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-medium-500.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-regular-400.otf
Normal file
BIN
web/app/src/assets/images/404.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
web/app/src/assets/images/500.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
web/app/src/assets/images/ai-loading.gif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
web/app/src/assets/images/answer.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
web/app/src/assets/images/banner-bg.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
web/app/src/assets/images/block.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
web/app/src/assets/images/dark-bgi.png
Normal file
|
After Width: | Height: | Size: 619 KiB |
BIN
web/app/src/assets/images/dot.png
Normal file
|
After Width: | Height: | Size: 268 B |
BIN
web/app/src/assets/images/feedback.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
web/app/src/assets/images/footer-logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
web/app/src/assets/images/light-bgi.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
web/app/src/assets/images/loading.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
web/app/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/app/src/assets/images/no-doc.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
web/app/src/assets/images/no-permission.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
web/app/src/assets/images/nodata.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
206
web/app/src/assets/type/index.ts
Normal 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[];
|
||||
}
|
||||
1238
web/app/src/components/QaModal/AiQaContent.tsx
Normal file
436
web/app/src/components/QaModal/SearchDocContent.tsx
Normal 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;
|
||||
344
web/app/src/components/QaModal/StyledComponents.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
15
web/app/src/components/QaModal/constants.ts
Normal 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;
|
||||
282
web/app/src/components/QaModal/index.tsx
Normal 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;
|
||||
32
web/app/src/components/QaModal/types.ts
Normal 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;
|
||||
}
|
||||
16
web/app/src/components/QaModal/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
448
web/app/src/components/commentInput/index.tsx
Normal 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;
|
||||
133
web/app/src/components/docFab/index.tsx
Normal 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;
|
||||
58
web/app/src/components/docSkeleton/index.tsx
Normal 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;
|
||||
29
web/app/src/components/emoji/emoji-data/zh.json
Normal 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": "黑色"
|
||||
}
|
||||
}
|
||||
117
web/app/src/components/emoji/index.tsx
Normal 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;
|
||||
30
web/app/src/components/emptyDocPlaceholder/index.tsx
Normal 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;
|
||||
75
web/app/src/components/error/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
web/app/src/components/feedback/index.tsx
Normal 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;
|
||||
49
web/app/src/components/footer/Overlay.tsx
Normal 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;
|
||||
97
web/app/src/components/footer/index.tsx
Normal 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;
|
||||
181
web/app/src/components/header/index.tsx
Normal 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;
|
||||
23
web/app/src/components/header/themeSwitch.tsx
Normal 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;
|
||||
145
web/app/src/components/icons/index.tsx
Normal 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';
|
||||
273
web/app/src/components/markdown/index.tsx
Normal 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;
|
||||
51
web/app/src/components/markdown/mermaid.tsx
Normal 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;
|
||||
311
web/app/src/components/markdown2/imageRenderer.tsx
Normal 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;
|
||||
};
|
||||
};
|
||||
97
web/app/src/components/markdown2/incrementalRenderer.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
574
web/app/src/components/markdown2/index.tsx
Normal 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 {
|
||||
// 渲染markdown(thinking标签在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;
|
||||
58
web/app/src/components/markdown2/mermaidRenderer.tsx
Normal 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>`;
|
||||
};
|
||||
};
|
||||
128
web/app/src/components/markdown2/thinkingRenderer.tsx
Normal 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;
|
||||
};
|
||||
196
web/app/src/components/menuSelect/index.tsx
Normal 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;
|
||||
55
web/app/src/components/scrollToTopFab/index.tsx
Normal 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;
|
||||
38
web/app/src/components/watermark/WaterMarkProvider.tsx
Normal 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;
|
||||
272
web/app/src/components/watermark/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
web/app/src/constant/index.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
7
web/app/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useCopy } from './useCopy';
|
||||
export { useSmartScroll } from './useSmartScroll';
|
||||
export type {
|
||||
UseSmartScrollOptions,
|
||||
UseSmartScrollReturn,
|
||||
} from './useSmartScroll';
|
||||
export { useBasePath } from './useBasePath';
|
||||
8
web/app/src/hooks/useBasePath.ts
Normal 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);
|
||||
};
|
||||
189
web/app/src/hooks/useCopy.tsx
Normal 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;
|
||||
154
web/app/src/hooks/useScroll.ts
Normal 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;
|
||||
369
web/app/src/hooks/useSmartScroll.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
web/app/src/hooks/useSyncNavByDocId.ts
Normal 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]);
|
||||
}
|
||||
34
web/app/src/instrumentation-client.ts
Normal 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;
|
||||
20
web/app/src/instrumentation.ts
Normal 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;
|
||||
204
web/app/src/provider/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
web/app/src/provider/themeStore.tsx
Normal 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
@@ -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',
|
||||
],
|
||||
};
|
||||
59
web/app/src/request/ShareApp.ts
Normal 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,
|
||||
});
|
||||
100
web/app/src/request/ShareAuth.ts
Normal 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,
|
||||
});
|
||||
60
web/app/src/request/ShareCaptcha.ts
Normal 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,
|
||||
});
|
||||
120
web/app/src/request/ShareChat.ts
Normal 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,
|
||||
});
|
||||
48
web/app/src/request/ShareChatSearch.ts
Normal 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,
|
||||
});
|
||||
79
web/app/src/request/ShareComment.ts
Normal 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,
|
||||
});
|
||||
48
web/app/src/request/ShareConversation.ts
Normal 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,
|
||||
});
|
||||
80
web/app/src/request/ShareFile.ts
Normal 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,
|
||||
});
|
||||
37
web/app/src/request/ShareNav.ts
Normal 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,
|
||||
});
|
||||
67
web/app/src/request/ShareNode.ts
Normal 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,
|
||||
});
|
||||