init push

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

43
web/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
*.local
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
/dist/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin

23
web/app/.prettierignore Normal file
View File

@@ -0,0 +1,23 @@
# Build outputs
.next
out
build
coverage
dist
# Package managers
node_modules
pnpm-lock.yaml
yarn.lock
package-lock.json
# Logs
*.log
# Generated
public
api-templates
src/request
# Misc
.DS_Store

24
web/app/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:22-alpine
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
WORKDIR /app
# 复制 public 目录
COPY --chown=nextjs:nodejs public ./app/public
# 复制 standalone 构建输出到工作目录(包含可能的额外 app 子目录)
COPY --chown=nextjs:nodejs dist/standalone ./
# 复制静态资源到可能的两种相对路径位置
COPY --chown=nextjs:nodejs dist/static ./app/dist/static
USER nextjs
EXPOSE 3010
ENV PORT=3010
ENV HOSTNAME="0.0.0.0"
CMD ["node", "app/server.js"]

21
web/app/Makefile Normal file
View File

@@ -0,0 +1,21 @@
PLATFORM=linux/amd64
TAG=main
REGISTRY=panda-wiki-app
# 构建前端代码
build:
pnpm run build
# 构建并加载到本地Docker
image: build
docker buildx build \
-f Dockerfile \
--platform ${PLATFORM} \
--tag ${REGISTRY}/frontend:${TAG} \
--load \
.
save: image
docker save -o /tmp/panda-wiki-app_frontend.tar panda-wiki-app/frontend:main

71
web/app/README.md Normal file
View File

@@ -0,0 +1,71 @@
# PandaWiki
## 项目描述
PandaWiki 是一个基于 Next.js 的 Wiki 知识库系统,支持文档管理、搜索和分类功能。
## 技术栈
- **前端框架**: Next.js 15.3.2, React 19
- **UI 组件库**: Material-UI (@mui/material 7.1.0)
- **状态管理**: 内置 React Hooks
- **Markdown 解析**: markdown-it, react-markdown
- **代码规范**: ESLint, TypeScript 5
- **包管理**: pnpm
- **API 文档**: cx-swagger-api
## 安装与运行
1. 克隆项目:
```bash
git clone https://github.com/your-repo/PandaWiki.git
```
2. 安装依赖:
```bash
pnpm install
```
3. 配置环境变量:
- 在项目根目录下,新建文件 `.env.local` , 根据需求修改环境变量,实际字段如下:
```env
# 目标服务配置
TARGET=http://your_target_ip:8000 # 后端服务地址
STATIC_FILE_TARGET=https://your_static_file_ip:2443 # 静态文件服务地址
# 开发相关
DEV_KB_ID=your_dev_kb_id # 开发环境知识库ID
# Swagger 配置
SWAGGER_BASE_URL=http://your_swagger_ip:8000 # Swagger API 文档地址
SWAGGER_AUTH_TOKEN=your_swagger_token # Swagger 认证令牌
```
4. 启动开发服务器:
```bash
pnpm dev
```
5. 构建生产版本:
```bash
pnpm build
```
6. 启动生产服务器:
```bash
pnpm start
```
## 可用命令
- `pnpm dev`: 开发模式 (端口 3010)
- `pnpm build`: 构建生产版本
- `pnpm start`: 启动生产服务器
- `pnpm lint`: 代码检查
- `pnpm api`: 生成 API 文档(环境变量需提供 `SWAGGER_BASE_URL`、`SWAGGER_AUTH_TOKEN`)
## 开发指南
1. 确保代码符合 ESLint 和 Stylelint 规范。
2. 如需代码格式化,运行:
```bash
pnpm format
```
3. 提交 Pull Request 时描述清楚改动内容。

View File

@@ -0,0 +1,18 @@
<%
const { utils, route, config, modelTypes } = it;
const { _, pascalCase, require } = utils;
const apiClassName = pascalCase(route.moduleName);
const routes = route.routes;
const dataContracts = _.map(modelTypes, "name");
%>
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
import httpRequest, { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>";
<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
<% } %>
<% for (const route of routes) { %>
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
<% } %>

View File

@@ -0,0 +1,304 @@
<%
const { apiConfig, generateResponses, config } = it;
%>
import { message as alert } from "@ctzhian/ui";
import { notFound, redirect } from 'next/navigation';
import { getServerHeader, getServerPathname, getServerSearch, getServerBasePath } from '@/utils/getServerHeader';
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path"> & { isAlert?: boolean }
export interface DomainResponse {
/** @example 200 */
code?: number;
data?: any;
/** @example "OK" */
message?: string;
}
type ExtractDataProp<T> = T extends { data?: infer U } ? U : never;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
const pathnameWhiteList = ['/auth/login'];
const redirectToLogin = () => {
const redirectAfterLogin = encodeURIComponent(
location.href.replace(location.origin, '')
);
const search = `redirect=${redirectAfterLogin}`;
const pathname = `${window._BASE_PATH_ || ''}/auth/login`;
window.location.href = [pathname, search]?.join('?');
};
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = '';
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: 'same-origin',
headers: {},
redirect: 'follow',
referrerPolicy: 'no-referrer',
}
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
}
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input,
[ContentType.FormData]: (input: any) =>
Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob ?
property :
typeof property === "object" && property !== null ?
JSON.stringify(property) :
`${property}`
);
return formData;
}, new FormData()),
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
}
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
}
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken)
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
}
public request = async <T = any, E = any>({
isAlert = true,
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
<% if (config.unwrapResponseData) { %>
}: FullRequestParams & { isAlert?: boolean }): Promise<ExtractDataProp<T>> => {
<% } else { %>
}: FullRequestParams & { isAlert?: boolean }): Promise<HttpResponse<T, E>> => {
<% } %>
const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format || "json";
let customHeaders = {};
if (typeof window === 'undefined') {
customHeaders = await getServerHeader();
}
return this.customFetch(
`${baseUrl || this.baseUrl || (typeof window !== 'undefined' ? window._BASE_PATH_ : '')}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...customHeaders,
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
},
signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
}
).then(async (response) => {
if (response.status === 401) {
if (typeof window === 'undefined') {
const pathname = await getServerPathname();
if (!pathnameWhiteList.includes(pathname)) {
const search = await getServerSearch();
const basePath = await getServerBasePath();
redirect(`${basePath}/auth/login?redirect=${encodeURIComponent(pathname +search)}`);
}
return
}
if (typeof window !== 'undefined') {
if (!pathnameWhiteList.includes(window.location.pathname)) {
if (response.status === 401) {
redirectToLogin();
}
}
return
}
}
// if (response.status === 403) {
// console.log("response 403:", response);
// if (typeof window === "undefined") {
// const pathname = await getServerPathname();
// if (pathname !== "/block") {
// redirect("/block");
// }
// }
// if (typeof window !== "undefined") {
// const pathname = window.location.pathname;
// if (pathname !== "/block") {
// window.location.href = "/block";
// }
// }
// return Promise.reject(403);
// }
// if (response.status === 404) {
// if (typeof window === "undefined") {
// notFound();
// }
// }
let data: any = {};
try {
data = await response[responseFormat]();
} catch (error) {}
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
<% if (!config.disableThrowOnError) { %>
if (!response.ok || (data.code !== undefined && data.code !== 0) || (data.success !== undefined && !data.success)) {
if (typeof window !== 'undefined') {
const urlObj = new URL(response.url);
if (urlObj.pathname !== '/api/v1/user/profile') {
if (isAlert) {
alert.error(
(data as DomainResponse).message! || response.statusText
);
}
}
}
const errorMessage = { data, url: response.url, response };
console.log("response error:", errorMessage);
return Promise.reject({...data, code: response.status === 200 ? data.code : response.status});
}
<% } %>
<% if (config.unwrapResponseData) { %>
return data.data;
<% } else { %>
return data;
<% } %>
});
};
}
export default new HttpClient({ baseUrl: process.env.TARGET }).request;

View File

@@ -0,0 +1,102 @@
<%
const { utils, route, config } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
const { type, errorType, contentTypes } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const routeDocs = includeFile("./route-docs", { config, route, utils });
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
const requestConfigParam = {
name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES),
optional: true,
type: "RequestParams",
defaultValue: "{}",
}
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const rawWrapperArgs = config.extractRequestParams ?
_.compact([
requestParams && {
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
optional: false,
type: getInlineParseContent(requestParams),
},
...(!requestParams ? pathParams : []),
payload,
requestConfigParam,
]) :
_.compact([
...pathParams,
query,
payload,
requestConfigParam,
])
const wrapperArgs = _
// Sort by optionality
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
// RequestParams["type"]
const requestContentKind = {
"JSON": "ContentType.Json",
"URL_ENCODED": "ContentType.UrlEncoded",
"FORM_DATA": "ContentType.FormData",
"TEXT": "ContentType.Text",
}
// RequestParams["format"]
const responseContentKind = {
"JSON": '"json"',
"IMAGE": '"blob"',
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
const describeReturnType = () => {
if (!config.toJS) return "";
switch(config.httpClientType) {
case HTTP_CLIENT.AXIOS: {
return `Promise<AxiosResponse<${type}>>`
}
default: {
return `Promise<HttpResponse<${type}, ${errorType}>`
}
}
}
%>
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
export const <%~ route.routeName.usage %> = (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> =>
httpRequest<<%~ type %>>({
path: `<%~ path %>`,
method: '<%~ _.upperCase(method) %>',
<%~ queryTmpl ? `query: ${queryTmpl},` : '' %>
<%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %>
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})

24
web/app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,24 @@
import { FlatCompat } from '@eslint/eslintrc';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'react/no-unescaped-entities': 'off',
},
},
];
export default eslintConfig;

22
web/app/new-types.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/// <reference types="@panda-wiki/themes/types" />
declare module '@cap.js/widget' {
interface CapOptions {
apiEndpoint: string;
}
class Cap {
constructor(options: CapOptions);
solve(): Promise<{ token: string }>;
}
export default Cap;
}
declare global {
interface Window {
_BASE_PATH_?: string;
}
}
export {};

96
web/app/next.config.ts Normal file
View File

@@ -0,0 +1,96 @@
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
distDir: 'dist',
reactStrictMode: false,
allowedDevOrigins: ['10.10.18.71'],
output: 'standalone',
assetPrefix: '/panda-wiki-app-assets',
logging: {
fetches: {
fullUrl: true,
},
},
images: {
unoptimized: true,
},
transpilePackages: ['mermaid'],
async headers() {
return [
{
source: '/cap@0.0.6/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, must-revalidate',
},
],
},
];
},
async rewrites() {
const rewritesPath = [];
if (process.env.NODE_ENV === 'development') {
rewritesPath.push(
...[
{
source: '/static-file/:path*',
destination: `${process.env.STATIC_FILE_TARGET}/static-file/:path*`,
basePath: false as const,
},
{
source: '/:basePath/static-file/:path*',
destination: `${process.env.STATIC_FILE_TARGET}/static-file/:path*`,
basePath: false as const,
},
{
source: '/share/v1/:path*',
destination: `${process.env.TARGET}/share/v1/:path*`,
basePath: false as const,
},
],
);
}
return rewritesPath;
},
};
// 在开发环境下跳过 Sentry 配置
const isDevelopment = process.env.NODE_ENV === 'development';
export default isDevelopment
? nextConfig
: withSentryConfig(nextConfig, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'sentry',
project: 'pandawiki-app',
sentryUrl: 'https://sentry.baizhi.cloud/',
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js proxy, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
});

64
web/app/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "panda-wiki-app",
"version": "2.9.0",
"private": true,
"scripts": {
"dev": "next dev -p 3010",
"build": "next build",
"start": "next start",
"lint": "next lint",
"api": "cx-swagger-api",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md,mdx,json,yml,yaml,mjs,cjs}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,css,scss,md,mdx,json,yml,yaml,mjs,cjs}\""
},
"dependencies": {
"@cap.js/widget": "^0.1.26",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@emotion/cache": "^11.14.0",
"@mui/material-nextjs": "^7.3.5",
"@sentry/nextjs": "^10.8.0",
"@types/markdown-it": "13.0.1",
"@vscode/markdown-it-katex": "^1.1.2",
"ahooks": "^3.9.0",
"axios": "^1.9.0",
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.5",
"html-to-image": "^1.11.13",
"import-in-the-middle": "^1.4.0",
"js-cookie": "^3.0.5",
"katex": "^0.16.22",
"markdown-it": "13.0.1",
"markdown-it-highlightjs": "^4.2.0",
"mermaid": "^11.9.0",
"next": "16.0.10",
"react-device-detect": "^2.2.3",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"require-in-the-middle": "^7.5.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@ctzhian/cx-swagger-api": "^1.0.0",
"@eslint/eslintrc": "^3",
"@next/eslint-plugin-next": "^16.0.0",
"@types/js-cookie": "^3.0.6",
"@types/rangy": "^1.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint-config-next": "16.0.0",
"eslint-config-prettier": "^9.1.2"
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
"pnpm": {
"overrides": {
"require-in-the-middle": "^7.5.2"
}
}
}

View File

@@ -0,0 +1,19 @@
module.exports = {
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: true,
quoteProps: 'as-needed',
jsxSingleQuote: true,
trailingComma: 'all',
bracketSpacing: true,
bracketSameLine: false,
arrowParens: 'avoid',
rangeStart: 0,
rangeEnd: Infinity,
requirePragma: false,
insertPragma: false,
proseWrap: 'preserve',
htmlWhitespaceSensitivity: 'css',
endOfLine: 'lf',
};

View File

@@ -0,0 +1,7 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@cap.js/wasm@0.0.6/browser/cap_wasm.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
let wasm,WASM_VECTOR_LEN=0,cachedUint8ArrayMemory0=null;function getUint8ArrayMemory0(){return null!==cachedUint8ArrayMemory0&&0!==cachedUint8ArrayMemory0.byteLength||(cachedUint8ArrayMemory0=new Uint8Array(wasm.memory.buffer)),cachedUint8ArrayMemory0}const cachedTextEncoder="undefined"!=typeof TextEncoder?new TextEncoder("utf-8"):{encode:()=>{throw Error("TextEncoder not available")}},encodeString="function"==typeof cachedTextEncoder.encodeInto?function(e,t){return cachedTextEncoder.encodeInto(e,t)}:function(e,t){const n=cachedTextEncoder.encode(e);return t.set(n),{read:e.length,written:n.length}};function passStringToWasm0(e,t,n){if(void 0===n){const n=cachedTextEncoder.encode(e),i=t(n.length,1)>>>0;return getUint8ArrayMemory0().subarray(i,i+n.length).set(n),WASM_VECTOR_LEN=n.length,i}let i=e.length,o=t(i,1)>>>0;const r=getUint8ArrayMemory0();let a=0;for(;a<i;a++){const t=e.charCodeAt(a);if(t>127)break;r[o+a]=t}if(a!==i){0!==a&&(e=e.slice(a)),o=n(o,i,i=a+3*e.length,1)>>>0;const t=getUint8ArrayMemory0().subarray(o+a,o+i);a+=encodeString(e,t).written,o=n(o,i,a,1)>>>0}return WASM_VECTOR_LEN=a,o}export function solve_pow(e,t){const n=passStringToWasm0(e,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),i=WASM_VECTOR_LEN,o=passStringToWasm0(t,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),r=WASM_VECTOR_LEN,a=wasm.solve_pow(n,i,o,r);return BigInt.asUintN(64,a)}async function __wbg_load(e,t){if("function"==typeof Response&&e instanceof Response){if("function"==typeof WebAssembly.instantiateStreaming)try{return await WebAssembly.instantiateStreaming(e,t)}catch(t){if("application/wasm"==e.headers.get("Content-Type"))throw t;console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",t)}const n=await e.arrayBuffer();return await WebAssembly.instantiate(n,t)}{const n=await WebAssembly.instantiate(e,t);return n instanceof WebAssembly.Instance?{instance:n,module:e}:n}}function __wbg_get_imports(){const e={wbg:{}};return e.wbg.__wbindgen_init_externref_table=function(){const e=wasm.__wbindgen_export_0,t=e.grow(4);e.set(0,void 0),e.set(t+0,void 0),e.set(t+1,null),e.set(t+2,!0),e.set(t+3,!1)},e}function __wbg_init_memory(e,t){}function __wbg_finalize_init(e,t){return wasm=e.exports,__wbg_init.__wbindgen_wasm_module=t,cachedUint8ArrayMemory0=null,wasm.__wbindgen_start(),wasm}function initSync(e){if(void 0!==wasm)return wasm;void 0!==e&&(Object.getPrototypeOf(e)===Object.prototype?({module:e}=e):console.warn("using deprecated parameters for `initSync()`; pass a single object instead"));const t=__wbg_get_imports();__wbg_init_memory(t),e instanceof WebAssembly.Module||(e=new WebAssembly.Module(e));return __wbg_finalize_init(new WebAssembly.Instance(e,t),e)}async function __wbg_init(e){if(void 0!==wasm)return wasm;void 0!==e&&(Object.getPrototypeOf(e)===Object.prototype?({module_or_path:e}=e):console.warn("using deprecated parameters for the initialization function; pass a single object instead")),void 0===e&&(e=new URL("cap_wasm_bg.wasm",import.meta.url));const t=__wbg_get_imports();("string"==typeof e||"function"==typeof Request&&e instanceof Request||"function"==typeof URL&&e instanceof URL)&&(e=fetch(e)),__wbg_init_memory(t);const{instance:n,module:i}=await __wbg_load(await e,t);return __wbg_finalize_init(n,i)}export{initSync};export default __wbg_init;

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,315 @@
/* 挂件按钮基础样式 */
.widget-bot-button {
position: fixed;
z-index: 9999;
font-size: 14px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease-in-out;
color: #646a73;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
border: none;
outline: none;
opacity: 0;
/* 优化拖拽性能 */
will-change: transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
transform-origin: center;
}
.widget-bot-button:hover {
transform: scale(1.1);
}
.widget-bot-button.dragging {
cursor: grabbing;
}
.widget-bot-button-content {
display: flex;
flex-direction: column;
align-items: center;
color: inherit;
}
/* 图标样式 */
.widget-bot-icon {
border-radius: 50%;
object-fit: cover;
}
/* 文字样式 */
.widget-bot-text {
font-size: 14px;
font-weight: 400;
line-height: 1.2;
text-align: center;
letter-spacing: 0.5px;
}
.widget-bot-text span {
display: block;
margin: 1px 0;
}
/* 侧边吸附按钮样式 */
.widget-bot-side-sticky {
width: 48px;
padding: 8px 8px 12px 8px;
background: #FFFFFF;
box-shadow: 0px 6px 30px 6px rgba(33, 34, 45, 0.03), 0px 4px 12px -6px rgba(33, 34, 45, 0.05), 0px 4px 12px 0px rgba(33, 34, 45, 0.03);
border-radius: 20px;
min-height: auto;
}
/* 浅色主题样式 - 显式定义 */
.widget-bot-side-sticky[data-theme="dark"] {
background: #202531;
color: #FFFFFF;
}
.widget-bot-side-sticky .widget-bot-icon {
width: 32px;
height: 32px;
margin-bottom: 4px;
}
.widget-bot-side-sticky .widget-bot-text {
font-size: 12px;
/* color: #646a73; */
line-height: 16px;
}
/* 悬浮球按钮样式 */
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
padding: 0;
min-height: auto;
}
/* 浅色主题样式 - 显式定义 */
.widget-bot-hover-ball[data-theme="dark"] {
background: #202531;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 32px;
height: 32px;
margin-bottom: 0;
}
/* 模态框样式 - 基于MUI主题 */
.widget-bot-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.widget-bot-modal-fixed {
align-items: center;
justify-content: center;
}
.widget-bot-modal-content {
position: absolute;
width: 600px;
height: 725px;
max-width: calc(100% - 32px);
max-height: 80vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
animation: slideInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.widget-bot-modal-content-fixed {
position: relative;
width: 800px;
height: auto;
max-height: 90vh;
margin: auto;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(24px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 关闭按钮样式 - 透明框 */
.widget-bot-close-btn {
position: absolute;
top: 22.5px;
right: 16px;
background: transparent;
width: 36.26px;
height: 25px;
border: none;
border-radius: 0;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
opacity: 1;
z-index: 10001;
transition: none;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
padding: 0;
margin: 0;
pointer-events: none;
/* 允许鼠标穿透到下方 */
}
/* iframe样式 */
.widget-bot-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 10px;
display: block;
background: #F8F9FA;
}
.widget-bot-modal-content-fixed .widget-bot-iframe {
min-height: 600px;
height: auto;
}
/* 防止页面滚动 */
body.widget-bot-modal-open {
overflow: hidden;
}
/* 移动端适配 - 统一处理 */
@media (max-width: 768px) {
.widget-bot-side-sticky {
width: 48px;
padding: 6px 6px 12px 6px;
border-radius: 24px;
}
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
padding: 0;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 48px;
height: 48px;
margin-bottom: 0;
}
.widget-bot-text {
font-size: 12px;
}
.widget-bot-icon {
width: 16px;
height: 16px;
margin-bottom: 6px;
}
/* 移动端弹框统一居中显示宽度100%-32px高度90vh */
.widget-bot-modal-content {
position: relative !important;
width: calc(100% - 32px) !important;
height: 90vh !important;
max-width: none !important;
max-height: 90vh !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
margin: auto !important;
}
.widget-bot-modal-content-fixed {
width: calc(100% - 32px) !important;
height: 90vh !important;
max-height: 90vh !important;
}
.widget-bot-close-btn {
top: 22.5px;
right: 16px;
width: 36.26px;
height: 25px;
font-size: 0;
}
}
/* 确保挂件不会被其他元素遮挡 */
.widget-bot-button,
.widget-bot-modal {
pointer-events: auto;
}
/* 加载状态 */
.widget-bot-button.loading {
opacity: 0.7;
pointer-events: none;
}
.widget-bot-button.loading::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #FFFFFF;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 减少动画模式支持 */
@media (prefers-reduced-motion: reduce) {
.widget-bot-button,
.widget-bot-modal-content,
.widget-bot-close-btn,
.widget-bot-text span {
animation: none;
transition: none;
}
.widget-bot-button:hover,
.widget-bot-button.dragging {
transform: none;
}
}

View File

@@ -0,0 +1,950 @@
(function () {
'use strict';
const defaultModalPosition = 'follow';
const defaultBtnPosition = 'bottom_right';
const defaultBtnStyle = 'side_sticky';
// 获取当前脚本的域名
const currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]');
const ulrObj = new URL(currentScript.src)
const widgetDomain = `${ulrObj.origin}${ulrObj.pathname}`.replace('/widget-bot.js', '')
let widgetInfo = null;
let widgetButton = null;
let widgetModal = null;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let currentTheme = 'light'; // 默认浅色主题
let customTriggerElement = null; // 自定义触发元素
let customTriggerHandler = null; // 自定义触发元素的事件处理函数
let dragAnimationFrame = null; // 拖拽动画帧ID
let buttonSize = { width: 0, height: 0 }; // 缓存按钮尺寸
let initialPosition = { left: 0, top: 0 }; // 拖拽开始时的初始位置
let hasDragged = false; // 标记是否发生了拖拽
let dragStartPos = { x: 0, y: 0 }; // 拖拽开始时的鼠标位置
// 应用主题
function applyTheme(theme_mode) {
currentTheme = theme_mode === 'dark' ? 'dark' : 'light';
updateThemeClasses();
}
// 更新主题类名
function updateThemeClasses() {
if (widgetButton) {
widgetButton.setAttribute('data-theme', currentTheme);
}
if (widgetModal) {
widgetModal.setAttribute('data-theme', currentTheme);
}
}
// 获取挂件信息
async function fetchWidgetInfo() {
if (widgetButton) {
widgetButton.classList.add('loading');
}
try {
const response = await fetch(`${widgetDomain}/share/v1/app/widget/info`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
widgetInfo = data.data.settings?.widget_bot_settings;
// 验证返回的数据结构
if (!widgetInfo || typeof widgetInfo !== 'object') {
throw new Error('Invalid widget info response');
}
// 应用主题模式
if (widgetInfo.theme_mode) {
applyTheme(widgetInfo.theme_mode);
}
// 根据 btn_style 创建不同的挂件
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'btn_trigger') {
createCustomTrigger();
} else {
createWidget();
}
} catch (error) {
console.error('获取挂件信息失败:', error);
// 使用默认值
widgetInfo = {
btn_text: '在线客服',
btn_logo: `''`,
btn_style: defaultBtnStyle,
btn_position: defaultBtnPosition,
modal_position: defaultModalPosition,
theme_mode: 'light'
};
applyTheme(widgetInfo.theme_mode);
createWidget();
} finally {
if (widgetButton) {
widgetButton.classList.remove('loading');
}
}
}
// 应用按钮位置
function applyButtonPosition(button, position) {
const pos = position || defaultBtnPosition;
button.style.top = 'auto';
button.style.right = 'auto';
button.style.bottom = 'auto';
button.style.left = 'auto';
// 两种模式使用相同的默认位置距离边缘16px垂直方向190px
switch (pos) {
case 'top_left':
button.style.top = '190px';
button.style.left = '16px';
break;
case 'top_right':
button.style.top = '190px';
button.style.right = '16px';
break;
case 'bottom_left':
button.style.bottom = '190px';
button.style.left = '16px';
break;
case 'bottom_right':
default:
button.style.bottom = '190px';
button.style.right = '16px';
break;
}
}
// 创建侧边吸附按钮
function createSideStickyButton() {
widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button widget-bot-side-sticky';
widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content';
// 侧边吸附显示图标和文字btn_logo 以及 btn_text
const icon = document.createElement('img');
const defaultIconSrc = widgetDomain + '/favicon.png';
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
icon.alt = 'icon';
icon.className = 'widget-bot-icon';
icon.onerror = () => {
// 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
if (icon.src !== defaultIconSrc) {
icon.src = defaultIconSrc;
} else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
// 添加文字
const textDiv = document.createElement('div');
textDiv.className = 'widget-bot-text';
textDiv.textContent = widgetInfo.btn_text || '在线客服';
// 设置固定宽度、自动换行和居中
textDiv.style.wordWrap = 'break-word';
textDiv.style.whiteSpace = 'normal';
textDiv.style.textAlign = 'center';
buttonContent.appendChild(textDiv);
widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
const position = widgetInfo.btn_position || defaultBtnPosition;
applyButtonPosition(widgetButton, position);
// 设置 border-radius 为 24px统一圆角
widgetButton.style.borderRadius = '24px';
// 添加事件监听器
widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown);
// 添加触摸事件支持
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton);
}
// 创建悬浮球按钮
function createHoverBallButton() {
widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button widget-bot-hover-ball';
widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content';
// 悬浮球只显示图标btn_logo
const icon = document.createElement('img');
const defaultIconSrc = widgetDomain + '/favicon.png';
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
icon.alt = 'icon';
icon.className = 'widget-bot-icon widget-bot-hover-ball-icon';
icon.onerror = () => {
// 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
if (icon.src !== defaultIconSrc) {
icon.src = defaultIconSrc;
} else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
applyButtonPosition(widgetButton, widgetInfo.btn_position || defaultBtnPosition);
// 添加事件监听器
widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown);
// 添加触摸事件支持
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton);
}
// 创建挂件按钮
function createWidget() {
// 如果已存在,先删除
if (widgetButton) {
widgetButton.remove();
}
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'hover_ball') {
createHoverBallButton();
} else {
createSideStickyButton();
}
// 创建模态框
createModal();
// 触发显示动画
setTimeout(() => {
widgetButton.style.opacity = '1';
}, 100);
}
// 创建自定义触发按钮
function createCustomTrigger() {
const btnId = widgetInfo.btn_id;
if (!btnId) {
console.error('btn_trigger 模式需要提供 btn_id');
return;
}
let retryCount = 0;
const maxRetries = 50; // 最多重试 50 次5秒
// 绑定事件到元素
function attachTrigger(element) {
if (!element) return;
// 避免重复绑定
if (element.hasAttribute('data-widget-trigger-attached')) {
return;
}
element.setAttribute('data-widget-trigger-attached', 'true');
customTriggerElement = element;
// 创建事件处理函数并保存引用
customTriggerHandler = function (e) {
e.preventDefault();
e.stopPropagation();
showModal();
};
// 绑定点击事件
element.addEventListener('click', customTriggerHandler);
}
// 尝试查找并绑定元素
function tryAttachTrigger() {
const element = document.getElementById(btnId);
if (element) {
attachTrigger(element);
createModal();
return true;
}
return false;
}
// 立即尝试一次
if (tryAttachTrigger()) {
return;
}
// 如果元素还没加载,使用多种方式监听
function retryAttach() {
if (tryAttachTrigger()) {
return;
}
retryCount++;
if (retryCount < maxRetries) {
setTimeout(retryAttach, 100);
} else {
console.warn('自定义触发按钮未找到,已停止重试:', btnId);
}
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(function (mutations) {
if (tryAttachTrigger()) {
observer.disconnect();
}
});
// 开始观察 DOM 变化
observer.observe(document.body, {
childList: true,
subtree: true
});
// 如果 DOM 已加载完成,立即开始重试
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
setTimeout(retryAttach, 100);
});
} else {
setTimeout(retryAttach, 100);
}
// 延迟断开观察器(避免无限观察)
setTimeout(function () {
observer.disconnect();
}, 10000); // 10秒后断开
}
// 处理按钮点击事件(区分点击和拖拽)
function handleButtonClick(e) {
// 如果发生了拖拽,不打开弹框
if (hasDragged) {
e.preventDefault();
e.stopPropagation();
return;
}
showModal();
}
// 键盘事件处理
function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showModal();
}
}
// 触摸事件处理
let touchStartPos = { x: 0, y: 0 };
function handleTouchStart(e) {
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
startDrag(e);
}
function handleTouchMove(e) {
if (!isDragging) return;
e.preventDefault()
const touch = e.touches[0];
drag({ clientX: touch.clientX, clientY: touch.clientY });
}
function handleTouchEnd(e) {
const touch = e.changedTouches[0];
const distance = Math.sqrt(
Math.pow(touch.clientX - touchStartPos.x, 2) +
Math.pow(touch.clientY - touchStartPos.y, 2)
);
// 只有在没有拖拽且移动距离很小的情况下才认为是点击
if (!hasDragged && distance < 10) {
// 判断为点击事件
setTimeout(() => showModal(), 100);
}
stopDrag();
}
// 创建模态框
function createModal() {
// 如果已存在,先删除
if (widgetModal) {
widgetModal.remove();
}
widgetModal = document.createElement('div');
widgetModal.className = 'widget-bot-modal';
widgetModal.setAttribute('role', 'dialog');
widgetModal.setAttribute('aria-modal', 'true');
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
widgetModal.setAttribute('data-theme', currentTheme);
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
if (modalPosition === 'fixed') {
widgetModal.classList.add('widget-bot-modal-fixed');
}
const modalContent = document.createElement('div');
modalContent.className = 'widget-bot-modal-content';
if (modalPosition === 'fixed') {
modalContent.classList.add('widget-bot-modal-content-fixed');
}
// 创建关闭按钮(透明框)
const closeBtn = document.createElement('button');
closeBtn.className = 'widget-bot-close-btn';
closeBtn.setAttribute('aria-label', '关闭窗口');
closeBtn.setAttribute('type', 'button');
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 pointer-events: none
const closeBtnArea = document.createElement('div');
closeBtnArea.style.width = '100%';
closeBtnArea.style.height = '100%';
closeBtnArea.style.pointerEvents = 'auto'; // 内部元素可以接收事件
closeBtnArea.style.cursor = 'pointer';
closeBtnArea.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
hideModal();
});
closeBtn.appendChild(closeBtnArea);
// 创建iframe
const iframe = document.createElement('iframe');
iframe.className = 'widget-bot-iframe';
iframe.src = `${widgetDomain}/widget`;
iframe.setAttribute('title', `${widgetInfo.btn_text || '在线客服'}服务窗口`);
iframe.setAttribute('allow', 'camera; microphone; geolocation');
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
modalContent.appendChild(closeBtn);
modalContent.appendChild(iframe);
widgetModal.appendChild(modalContent);
document.body.appendChild(widgetModal);
}
// 检测是否为移动端
function isMobile() {
return window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// 智能定位弹框follow模式
function positionModalFollow(modalContent) {
if (!widgetButton || !modalContent) return;
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
requestAnimationFrame(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const margin = 16; // 距离屏幕边缘的最小距离
const buttonGap = 16; // 弹框和按钮之间的最小距离
// 先设置一个临时位置来获取弹框尺寸
const originalPosition = modalContent.style.position;
const originalTop = modalContent.style.top;
const originalLeft = modalContent.style.left;
const originalVisibility = modalContent.style.visibility;
const originalDisplay = modalContent.style.display;
modalContent.style.position = 'absolute';
modalContent.style.top = '0';
modalContent.style.left = '0';
modalContent.style.visibility = 'hidden';
modalContent.style.display = 'block';
const modalRect = modalContent.getBoundingClientRect();
const modalWidth = modalRect.width;
const modalHeight = modalRect.height;
modalContent.style.visibility = originalVisibility || 'visible';
modalContent.style.display = originalDisplay || 'block';
// 计算按钮中心点
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
// 判断按钮在屏幕的哪一侧
const isLeftSide = buttonCenterX < windowWidth / 2;
const isTopSide = buttonCenterY < windowHeight / 2;
// 智能选择弹框位置,确保完整显示
let finalTop, finalBottom, finalLeft, finalRight;
if (isLeftSide) {
// 按钮在左侧,弹框优先显示在右侧(按钮右侧)
finalLeft = buttonRect.right + buttonGap;
finalRight = 'auto';
// 如果右侧空间不够,显示在左侧(按钮左侧)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间也不够,则贴左边(但保持与按钮的距离)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalLeft = margin;
finalRight = 'auto';
}
}
} else {
// 按钮在右侧,弹框优先显示在左侧(按钮左侧)
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间不够,显示在右侧(按钮右侧)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalRight = 'auto';
finalLeft = buttonRect.right + buttonGap;
// 如果右侧空间也不够,则贴右边(但保持与按钮的距离)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = margin;
}
}
}
// 垂直方向:优先与按钮顶部对齐
// 弹框顶部与按钮顶部对齐
finalTop = buttonRect.top;
finalBottom = 'auto';
// 如果弹框底部超出屏幕,则向上调整,确保弹框完整显示在屏幕内
if (finalTop + modalHeight > windowHeight - margin) {
// 计算向上调整后的位置
const adjustedTop = windowHeight - margin - modalHeight;
// 如果调整后的位置仍然在按钮上方,则使用调整后的位置
if (adjustedTop >= margin) {
finalTop = adjustedTop;
} else {
// 如果调整后仍然超出,则贴顶部
finalTop = margin;
}
} else if (finalTop < margin) {
// 如果弹框顶部超出屏幕,则贴顶部
finalTop = margin;
}
// 应用最终位置
modalContent.style.top = finalTop !== undefined ? (typeof finalTop === 'string' ? finalTop : finalTop + 'px') : 'auto';
modalContent.style.bottom = finalBottom !== undefined ? (typeof finalBottom === 'string' ? finalBottom : finalBottom + 'px') : 'auto';
modalContent.style.left = finalLeft !== undefined ? (typeof finalLeft === 'string' ? finalLeft : finalLeft + 'px') : 'auto';
modalContent.style.right = finalRight !== undefined ? (typeof finalRight === 'string' ? finalRight : finalRight + 'px') : 'auto';
// 最终检查并修正,确保弹框完全在屏幕内
requestAnimationFrame(() => {
const finalModalRect = modalContent.getBoundingClientRect();
// 修正左边界
if (finalModalRect.left < margin) {
modalContent.style.left = margin + 'px';
modalContent.style.right = 'auto';
}
// 修正右边界
if (finalModalRect.right > windowWidth - margin) {
modalContent.style.right = margin + 'px';
modalContent.style.left = 'auto';
}
// 修正上边界
if (finalModalRect.top < margin) {
modalContent.style.top = margin + 'px';
modalContent.style.bottom = 'auto';
}
// 修正下边界
if (finalModalRect.bottom > windowHeight - margin) {
modalContent.style.bottom = margin + 'px';
modalContent.style.top = 'auto';
}
});
});
}
// 显示模态框
function showModal() {
if (!widgetModal) return;
widgetModal.style.display = 'flex';
document.body.classList.add('widget-bot-modal-open');
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
} else if (modalPosition === 'fixed') {
// 桌面端固定模式:居中展示
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
} else {
// 桌面端跟随模式:跟随按钮位置 - 智能定位,确保弹框完整显示在屏幕内
positionModalFollow(modalContent);
}
// 添加ESC键关闭功能先移除避免重复绑定
document.removeEventListener('keydown', handleEscKey);
document.addEventListener('keydown', handleEscKey);
}
// ESC键处理
function handleEscKey(e) {
// 只在弹框显示时响应 ESC 键
if (e.key === 'Escape' && widgetModal && widgetModal.style.display === 'flex') {
hideModal();
}
}
// 隐藏模态框
function hideModal() {
if (!widgetModal) return;
widgetModal.style.display = 'none';
document.body.classList.remove('widget-bot-modal-open');
// 恢复焦点到按钮
if (widgetButton) {
widgetButton.focus();
}
// 移除ESC键监听
document.removeEventListener('keydown', handleEscKey);
}
// 开始拖拽
function startDrag(e) {
if (e.preventDefault) {
e.preventDefault()
};
isDragging = true;
hasDragged = false; // 重置拖拽标记
const rect = widgetButton.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
// 记录拖拽开始位置
dragStartPos.x = clientX;
dragStartPos.y = clientY;
// 由于 transform-origin 是 centerscale 不会改变元素中心位置
// 但 getBoundingClientRect() 返回的尺寸是放大后的,需要计算原始尺寸
// 假设当前可能有 scale(1.1),计算原始尺寸
const scale = 1.1; // hover 时的 scale 值
const originalWidth = rect.width / scale;
const originalHeight = rect.height / scale;
// 缓存按钮原始尺寸(未缩放)
buttonSize.width = originalWidth;
buttonSize.height = originalHeight;
// 由于 transform-origin 是 center元素的左上角位置需要考虑 scale 的影响
// 中心点位置不变,但左上角会向左上移动
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const originalLeft = centerX - originalWidth / 2;
const originalTop = centerY - originalHeight / 2;
initialPosition.left = originalLeft;
initialPosition.top = originalTop;
// 计算鼠标相对于原始尺寸(未缩放)按钮左上角的偏移
dragOffset.x = clientX - originalLeft;
dragOffset.y = clientY - originalTop;
widgetButton.style.position = 'fixed';
widgetButton.style.top = originalTop + 'px';
widgetButton.style.left = originalLeft + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
// 保持 scale 效果
widgetButton.style.transform = 'scale(1.1)';
widgetButton.style.transition = 'none';
widgetButton.style.willChange = 'left, top, transform';
document.addEventListener('mousemove', drag, { passive: false });
document.addEventListener('mouseup', stopDrag);
widgetButton.classList.add('dragging');
widgetButton.style.zIndex = '10001';
}
// 拖拽中 - 直接更新位置,实现丝滑跟随
function drag(e) {
if (!isDragging) return;
if (e.preventDefault) {
e.preventDefault();
}
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
// 检测是否发生了实际移动超过5px才认为是拖拽
const moveDistance = Math.sqrt(
Math.pow(clientX - dragStartPos.x, 2) +
Math.pow(clientY - dragStartPos.y, 2)
);
if (moveDistance > 5) {
hasDragged = true;
}
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 直接基于鼠标位置计算新位置
// 鼠标位置减去拖拽偏移量,得到按钮左上角应该的位置
const newLeft = clientX - dragOffset.x;
const newTop = clientY - dragOffset.y;
// 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
const minTop = 24;
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
// 水平位置:限制在屏幕范围内
const maxLeft = windowWidth - buttonWidth;
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
widgetButton.style.left = constrainedLeft + 'px';
widgetButton.style.top = constrainedTop + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
// 保持 scale 效果
widgetButton.style.transform = 'scale(1.1)';
}
// 停止拖拽
function stopDrag() {
if (!isDragging) return;
isDragging = false;
// 取消待执行的动画帧
if (dragAnimationFrame) {
cancelAnimationFrame(dragAnimationFrame);
dragAnimationFrame = null;
}
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
widgetButton.classList.remove('dragging');
widgetButton.style.zIndex = '9999';
// 恢复过渡效果
widgetButton.style.transition = '';
widgetButton.style.willChange = '';
// 移除 transform让 CSS hover 效果可以正常工作
widgetButton.style.transform = '';
// 根据按钮类型和当前位置进行最终定位
requestAnimationFrame(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const currentLeft = buttonRect.left;
const currentTop = buttonRect.top;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
// 根据按钮实际位置判断左右,保持当前位置
const screenCenterX = windowWidth / 2;
const buttonCenterX = currentLeft + buttonWidth / 2;
const isLeftSide = buttonCenterX < screenCenterX;
const sideDistance = 16; // 距离边缘的距离
// 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 24px
const minTop = 24;
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
const finalTop = Math.max(minTop, Math.min(currentTop, maxTop));
let finalLeft;
// 水平位置距离左右边16px
if (isLeftSide) {
finalLeft = sideDistance;
widgetButton.style.left = sideDistance + 'px';
widgetButton.style.right = 'auto';
} else {
finalLeft = windowWidth - sideDistance - buttonWidth;
widgetButton.style.right = sideDistance + 'px';
widgetButton.style.left = 'auto';
}
widgetButton.style.top = finalTop + 'px';
widgetButton.style.bottom = 'auto';
// 更新 border-radius现在都是24px圆角
widgetButton.style.borderRadius = '24px';
// 更新初始位置,为下次拖拽做准备
if (finalLeft !== undefined && finalTop !== undefined) {
initialPosition.left = finalLeft;
initialPosition.top = finalTop;
} else {
// 如果未定义,使用当前实际位置
initialPosition.left = buttonRect.left;
initialPosition.top = buttonRect.top;
}
});
}
// 设置按钮状态
function setButtonState(state) {
if (!widgetButton) return;
widgetButton.classList.remove('success', 'error', 'loading');
if (state === 'success') {
widgetButton.classList.add('success');
} else if (state === 'error') {
widgetButton.classList.add('error');
} else if (state === 'loading') {
widgetButton.classList.add('loading');
}
}
// 更新主题模式
function updateThemeMode(theme_mode) {
if (theme_mode === 'light' || theme_mode === 'dark') {
applyTheme(theme_mode);
}
}
// 全局函数
window.hideWidgetModal = hideModal;
window.setWidgetButtonState = setButtonState;
window.updateWidgetTheme = updateThemeMode;
// 点击模态框背景关闭
document.addEventListener('click', function (e) {
if (e.target === widgetModal) {
hideModal();
}
});
// 窗口大小改变时重新定位
window.addEventListener('resize', function () {
if (widgetModal && widgetModal.style.display === 'flex') {
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
if (!modalContent) return;
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
if (modalPosition === 'fixed') {
// 固定居中模式不需要重新定位
return;
}
// 重新计算模态框位置(使用智能定位)
positionModalFollow(modalContent);
}
});
// 初始化
function init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchWidgetInfo);
} else {
fetchWidgetInfo();
}
}
// 页面卸载时清理
window.addEventListener('beforeunload', function () {
if (widgetButton) {
widgetButton.remove();
}
if (widgetModal) {
widgetModal.remove();
}
if (customTriggerElement && customTriggerHandler) {
customTriggerElement.removeEventListener('click', customTriggerHandler);
customTriggerElement.removeAttribute('data-widget-trigger-attached');
}
});
// 启动
init();
})();

View File

@@ -0,0 +1,19 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// 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',
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}

View File

@@ -0,0 +1,18 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// 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',
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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