init push
43
web/app/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 时描述清楚改动内容。
|
||||
18
web/app/api-templates/api.ejs
Normal 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 }) %>
|
||||
<% } %>
|
||||
304
web/app/api-templates/http-client.ejs
Normal 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;
|
||||
102
web/app/api-templates/procedure-call.ejs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
web/app/prettier.config.js
Normal 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',
|
||||
};
|
||||
7
web/app/public/cap@0.0.6/cap_wasm.min.js
vendored
Normal 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;
|
||||
BIN
web/app/public/cap@0.0.6/cap_wasm_bg.wasm
Normal file
BIN
web/app/public/favicon.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/app/public/images/init/banner_bg.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
web/app/public/images/init/brand_logo.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
web/app/public/images/init/carousel_ai_qa.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
web/app/public/images/init/carousel_data_statistics.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
web/app/public/images/init/carousel_doc_home.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
web/app/public/images/init/carousel_doc_manage.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
web/app/public/images/init/carousel_third_party_robot.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
web/app/public/images/init/carousel_web_robot.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
web/app/public/images/init/doc_create_wiki.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
web/app/public/images/init/doc_login.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/app/public/images/init/doc_model.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
web/app/public/images/init/doc_weixin_qrcode.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/app/public/images/init/github_icon.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/app/public/images/init/icon.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
web/app/public/images/init/weixin_qrcode.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
315
web/app/public/widget-bot.css
Normal 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;
|
||||
}
|
||||
}
|
||||
950
web/app/public/widget-bot.js
Normal 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 是 center,scale 不会改变元素中心位置
|
||||
// 但 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();
|
||||
})();
|
||||
|
||||
19
web/app/sentry.edge.config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
18
web/app/sentry.server.config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
5
web/app/src/app/(pages)/(doc)/editor/[[...id]]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DocEditor from '@/views/editor';
|
||||
|
||||
export default function EditorPage() {
|
||||
return <DocEditor />;
|
||||
}
|
||||
84
web/app/src/app/(pages)/(doc)/home/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import Home from '@/views/home';
|
||||
import { WelcomeFooter } from '@/components/footer';
|
||||
import { ThemeProvider } from '@ctzhian/ui';
|
||||
import { WelcomeHeader } from '@/components/header';
|
||||
import { Stack, createTheme } from '@mui/material';
|
||||
import { createComponentStyleOverrides } from '@/theme';
|
||||
import { useStore } from '@/provider';
|
||||
import { THEME_TO_PALETTE } from '@panda-wiki/themes/constants';
|
||||
|
||||
const HomePage = () => {
|
||||
const { kbDetail } = useStore();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
|
||||
const checkVisibility = () => {
|
||||
const elements = document.querySelectorAll('.banner-search-box');
|
||||
if (elements.length > 0) {
|
||||
// 判断是否还有任意一个搜索框处于可视区域内
|
||||
// 顶部预留 64px 为头部占用区域
|
||||
const hasVisibleBox = Array.from(elements).some(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.bottom > 64 && rect.top < window.innerHeight;
|
||||
});
|
||||
setShowSearch(!hasVisibleBox);
|
||||
} else {
|
||||
setShowSearch(window.scrollY >= window.innerHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
checkVisibility();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
checkVisibility();
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
// @ts-ignore
|
||||
const themeMode = kbDetail?.settings?.web_app_landing_theme?.name || 'blue';
|
||||
return createTheme({
|
||||
cssVariables: {
|
||||
cssVarPrefix: 'welcome',
|
||||
},
|
||||
palette:
|
||||
THEME_TO_PALETTE[themeMode]?.palette ||
|
||||
THEME_TO_PALETTE['blue'].palette,
|
||||
typography: {
|
||||
fontFamily: 'var(--font-gilory), PingFang SC, sans-serif',
|
||||
},
|
||||
components: createComponentStyleOverrides(true),
|
||||
});
|
||||
// @ts-ignore
|
||||
}, [kbDetail?.settings?.web_app_landing_theme?.name]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Stack
|
||||
justifyContent='space-between'
|
||||
sx={{ minHeight: '100vh', bgcolor: 'background.default' }}
|
||||
>
|
||||
<WelcomeHeader showSearch={showSearch} />
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Home />
|
||||
</Stack>
|
||||
<WelcomeFooter />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
7
web/app/src/app/(pages)/(doc)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <WaterMarkProvider>{children}</WaterMarkProvider>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
118
web/app/src/app/(pages)/(doc)/node/NodeClientLayout.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { FooterSetting } from '@/assets/type';
|
||||
import EmptyDocPlaceholder from '@/components/emptyDocPlaceholder';
|
||||
import { FooterProvider } from '@/components/footer';
|
||||
import Header from '@/components/header';
|
||||
import { CONTENT_GAP } from '@/constant';
|
||||
import { useSyncNavByDocId } from '@/hooks/useSyncNavByDocId';
|
||||
import { useStore } from '@/provider';
|
||||
import Catalog from '@/views/node/Catalog';
|
||||
import CatalogH5 from '@/views/node/CatalogH5';
|
||||
import NavBar from '@/views/node/NavBar';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const PCLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { tree, kbDetail, catalogWidth = 260 } = useStore();
|
||||
const docWidth = useMemo(
|
||||
() => kbDetail?.settings?.theme_and_style?.doc_width || 'full',
|
||||
[kbDetail],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ height: '100vh', overflow: 'auto' }} id='scroll-container'>
|
||||
<Header isDocPage={true} />
|
||||
<NavBar docWidth={docWidth} catalogWidth={catalogWidth} />
|
||||
{tree?.length === 0 ? (
|
||||
<EmptyDocPlaceholder />
|
||||
) : (
|
||||
<Stack sx={{ flex: 1, px: 5, alignItems: 'center' }}>
|
||||
<Stack
|
||||
direction='row'
|
||||
justifyContent='center'
|
||||
alignItems='flex-start'
|
||||
gap={`${CONTENT_GAP}px`}
|
||||
sx={{
|
||||
pt: '50px',
|
||||
pb: 10,
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Catalog />
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FooterProvider isDocPage={true} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileLayout = ({
|
||||
children,
|
||||
footerSetting,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
footerSetting?: FooterSetting | null;
|
||||
}) => {
|
||||
const { tree } = useStore();
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Header />
|
||||
<NavBar />
|
||||
{tree?.length === 0 ? (
|
||||
<EmptyDocPlaceholder mobile />
|
||||
) : (
|
||||
<>
|
||||
<CatalogH5 />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 5,
|
||||
bgcolor: 'background.paper3',
|
||||
...(footerSetting?.footer_style === 'complex' && {
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<FooterProvider />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default function NodeClientLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { mobile, kbDetail } = useStore();
|
||||
const footerSetting = kbDetail?.settings?.footer_settings;
|
||||
useSyncNavByDocId();
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<MobileLayout footerSetting={footerSetting}>{children}</MobileLayout>
|
||||
) : (
|
||||
<PCLayout>{children}</PCLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
web/app/src/app/(pages)/(doc)/node/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getShareV1NodeDetail } from '@/request/ShareNode';
|
||||
import type { V1ShareNodeDetailResp } from '@/request/types';
|
||||
import { formatMeta } from '@/utils';
|
||||
import Doc from '@/views/node';
|
||||
import { ResolvingMetadata } from 'next';
|
||||
|
||||
export interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const defaultNode = {
|
||||
name: '无权访问',
|
||||
meta: { summary: '无权访问' },
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: PageProps,
|
||||
parent: ResolvingMetadata,
|
||||
) {
|
||||
const { id } = await params;
|
||||
let node: { name?: string; meta?: { summary?: string } } = defaultNode;
|
||||
try {
|
||||
const res = await getShareV1NodeDetail({ id, format: 'json' });
|
||||
node = (res as V1ShareNodeDetailResp) ?? defaultNode;
|
||||
} catch {
|
||||
// 使用默认 node
|
||||
}
|
||||
return await formatMeta(
|
||||
{ title: node?.name, description: node?.meta?.summary },
|
||||
parent,
|
||||
);
|
||||
}
|
||||
|
||||
const DocPage = async ({ params }: PageProps) => {
|
||||
const { id = '' } = await params;
|
||||
let error: unknown = null;
|
||||
let node: V1ShareNodeDetailResp | null = null;
|
||||
try {
|
||||
const res = await getShareV1NodeDetail({ id, format: 'json' });
|
||||
node = (res as V1ShareNodeDetailResp) ?? null;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
return <Doc node={node ?? undefined} error={error as Error} />;
|
||||
};
|
||||
|
||||
export default DocPage;
|
||||
5
web/app/src/app/(pages)/(doc)/node/error.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import ErrorComponent from '@/components/error';
|
||||
|
||||
export default ErrorComponent;
|
||||
47
web/app/src/app/(pages)/(doc)/node/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import StoreProvider from '@/provider';
|
||||
import { getShareV1NodeList } from '@/request/ShareNode';
|
||||
import { parsePathname } from '@/utils';
|
||||
import { getServerPathname } from '@/utils/getServerHeader';
|
||||
import {
|
||||
convertToTree,
|
||||
filterEmptyFolders,
|
||||
parseNodeListResponse,
|
||||
} from '@/utils/tree';
|
||||
import NodeClientLayout from './NodeClientLayout';
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const [nodeListRes, pathname] = await Promise.all([
|
||||
getShareV1NodeList(),
|
||||
getServerPathname(),
|
||||
]);
|
||||
|
||||
const { page, id } = parsePathname(pathname);
|
||||
const nodeId = page === 'node' ? id : undefined;
|
||||
|
||||
const nodeListRaw = nodeListRes ?? [];
|
||||
const { isGrouped, navList, navDataMap, defaultNavId } =
|
||||
parseNodeListResponse(nodeListRaw, nodeId);
|
||||
|
||||
const nodeListForTree = isGrouped
|
||||
? (navDataMap[defaultNavId || ''] ?? navDataMap[Object.keys(navDataMap)[0]])
|
||||
: nodeListRaw;
|
||||
const tree = filterEmptyFolders(
|
||||
convertToTree((nodeListForTree || []) as any),
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreProvider
|
||||
nodeList={
|
||||
(Array.isArray(nodeListRaw) && !isGrouped ? nodeListRaw : []) as any
|
||||
}
|
||||
tree={tree}
|
||||
navList={navList}
|
||||
selectedNavId={defaultNavId || (navList[0]?.id ?? '')}
|
||||
navDataMap={navDataMap}
|
||||
>
|
||||
<NodeClientLayout>{children}</NodeClientLayout>
|
||||
</StoreProvider>
|
||||
);
|
||||
}
|
||||
21
web/app/src/app/(pages)/(doc)/node/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
import { useStore } from '@/provider';
|
||||
import { redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { deepSearchFirstNode } from '@/utils';
|
||||
import { useBasePath } from '@/hooks';
|
||||
|
||||
const NodePage = () => {
|
||||
const basePath = useBasePath();
|
||||
const { tree } = useStore();
|
||||
const firstNode = deepSearchFirstNode(tree || []);
|
||||
|
||||
if (firstNode) {
|
||||
return redirect(`${basePath}/node/${firstNode.id}`);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default NodePage;
|
||||
3
web/app/src/app/(pages)/(doc)/welcome/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HomePage from '../home/page';
|
||||
|
||||
export default HomePage;
|
||||
7
web/app/src/app/(pages)/auth/login/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Login from '@/views/auth/login';
|
||||
|
||||
const LoginPage = async () => {
|
||||
return <Login />;
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
38
web/app/src/app/(pages)/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
||||
import parse, { DOMNode, domToReact } from 'html-react-parser';
|
||||
import Script from 'next/script';
|
||||
|
||||
const Layout = async ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
const kbDetail = await getShareV1AppWebInfo();
|
||||
|
||||
const options = {
|
||||
replace(domNode: DOMNode) {
|
||||
if (domNode.type === 'script') {
|
||||
if (!domNode.children) return <Script {...domNode.attribs} />;
|
||||
return (
|
||||
<Script {...domNode.attribs}>
|
||||
{domToReact(domNode.children as any, options)}
|
||||
</Script>
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{kbDetail?.settings?.head_code ? (
|
||||
<>{parse(kbDetail.settings.head_code, options)}</>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
{kbDetail?.settings?.body_code && (
|
||||
<>{parse(kbDetail.settings.body_code, options)}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
48
web/app/src/app/(pages)/not-found.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import notFound from '@/assets/images/404.png';
|
||||
import { FooterProvider } from '@/components/footer';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
pt: 28,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxWidth: 1200,
|
||||
overflow: 'auto',
|
||||
pb: 6,
|
||||
mx: 'auto',
|
||||
}}
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
>
|
||||
<Image src={notFound} alt='404' width={380} height={200} />
|
||||
<Stack
|
||||
gap={3}
|
||||
alignItems='center'
|
||||
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
|
||||
>
|
||||
页面不存在
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
height: 40,
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<FooterProvider showBrand={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
27
web/app/src/app/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
import { FooterProvider } from '@/components/footer';
|
||||
import { Stack } from '@mui/material';
|
||||
import ErrorComponent from '@/components/error';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
sx={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Stack flex={1} justifyContent='center' alignItems='center'>
|
||||
<ErrorComponent error={error} reset={reset} />
|
||||
</Stack>
|
||||
<FooterProvider showBrand={false} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
BIN
web/app/src/app/favicon.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
19
web/app/src/app/feedback/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import StoreProvider from '@/provider';
|
||||
import { lightTheme } from '@/theme';
|
||||
import { ThemeProvider } from '@ctzhian/ui';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Layout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<StoreProvider themeMode={'light'}>{children}</StoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
17
web/app/src/app/feedback/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Feedback from '@/views/feedback';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
const FeedbackPage = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Feedback />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackPage;
|
||||
78
web/app/src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import ErrorPng from '@/assets/images/500.png';
|
||||
import Footer from '@/components/footer';
|
||||
import { lightTheme } from '@/theme';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import { ThemeProvider } from '@ctzhian/ui';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// 只在生产环境下上报错误到 Sentry
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
pt: 28,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxWidth: 1200,
|
||||
overflow: 'auto',
|
||||
pb: 6,
|
||||
mx: 'auto',
|
||||
}}
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
>
|
||||
<Image
|
||||
src={ErrorPng}
|
||||
unoptimized
|
||||
alt='404'
|
||||
width={380}
|
||||
height={200}
|
||||
/>
|
||||
<Stack
|
||||
gap={3}
|
||||
alignItems='center'
|
||||
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
|
||||
>
|
||||
页面出错了 {error.digest}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 40,
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Footer showBrand={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
87
web/app/src/app/globals.css
Normal file
@@ -0,0 +1,87 @@
|
||||
@import './markdown.css';
|
||||
@import '@ctzhian/tiptap/dist/index.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
var(--font-gilory), 'Roboto', 'Helvetica', 'Arial', sans-serif !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes loadingRotate {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
[class^='ellipsis-'] {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ellipsis-1 {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.ellipsis-2 {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.ellipsis-3 {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.ellipsis-4 {
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
/* 纵向滚动条*/
|
||||
height: 0;
|
||||
/* 横向滚动条隐藏 */
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影*/
|
||||
::-webkit-scrollbar-track {
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影*/
|
||||
::-webkit-scrollbar-thumb {
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||
background-color: #ccc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影*/
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||
background-color: #363636;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影*/
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
|
||||
background-color: #9b9b9b;
|
||||
border-radius: 10px;
|
||||
}
|
||||
3
web/app/src/app/h5-chat/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import H5Chat from '@/views/h5Chat';
|
||||
|
||||
export default H5Chat;
|
||||
134
web/app/src/app/layout.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import ErrorComponent from '@/components/error';
|
||||
import StoreProvider from '@/provider';
|
||||
import { ThemeStoreProvider } from '@/provider/themeStore';
|
||||
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
||||
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
|
||||
import Script from 'next/script';
|
||||
import { Box } from '@mui/material';
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import { headers, cookies } from 'next/headers';
|
||||
import { getSelectorsByUserAgent } from 'react-device-detect';
|
||||
import { getBasePath, getImagePath } from '@/utils';
|
||||
import './globals.css';
|
||||
|
||||
const gilory = localFont({
|
||||
variable: '--font-gilory',
|
||||
src: [
|
||||
{
|
||||
path: '../assets/fonts/gilroy-bold-700.otf',
|
||||
weight: '700',
|
||||
},
|
||||
{
|
||||
path: '../assets/fonts/gilroy-medium-500.otf',
|
||||
weight: '400',
|
||||
},
|
||||
{
|
||||
path: '../assets/fonts/gilroy-regular-400.otf',
|
||||
weight: '300',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const kbDetail: any = await getShareV1AppWebInfo();
|
||||
const basePath = getBasePath(kbDetail?.base_url || '');
|
||||
const icon = getImagePath(kbDetail?.settings?.icon || '', basePath);
|
||||
return {
|
||||
metadataBase: new URL(process.env.TARGET || ''),
|
||||
title: kbDetail?.settings?.title || 'Panda-Wiki',
|
||||
description: kbDetail?.settings?.desc || '',
|
||||
keywords: kbDetail?.settings?.keyword || '',
|
||||
icons: {
|
||||
icon: icon || `${basePath}/favicon.png`,
|
||||
},
|
||||
openGraph: {
|
||||
title: kbDetail?.settings?.title || 'Panda-Wiki',
|
||||
description: kbDetail?.settings?.desc || '',
|
||||
images: icon ? [icon] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Layout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const headersList = await headers();
|
||||
const userAgent = headersList.get('user-agent');
|
||||
const cookieStore = await cookies();
|
||||
const themeMode = (cookieStore.get('theme_mode')?.value || 'light') as
|
||||
| 'light'
|
||||
| 'dark';
|
||||
|
||||
let error: any = null;
|
||||
|
||||
const [kbDetailResolve, authInfoResolve] = await Promise.allSettled([
|
||||
getShareV1AppWebInfo(),
|
||||
getShareProV1AuthInfo({}),
|
||||
]);
|
||||
|
||||
const authInfo: any =
|
||||
authInfoResolve.status === 'fulfilled' ? authInfoResolve.value : undefined;
|
||||
const kbDetail: any =
|
||||
kbDetailResolve.status === 'fulfilled' ? kbDetailResolve.value : undefined;
|
||||
|
||||
if (
|
||||
authInfoResolve.status === 'rejected' &&
|
||||
authInfoResolve.reason.code === 403
|
||||
) {
|
||||
error = authInfoResolve.reason;
|
||||
}
|
||||
|
||||
const { isMobile } = getSelectorsByUserAgent(userAgent || '') || {
|
||||
isMobile: false,
|
||||
};
|
||||
|
||||
const basePath = getBasePath(kbDetail?.base_url || '');
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<Script
|
||||
id='base-path'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window._BASE_PATH_ = '${basePath}';`,
|
||||
}}
|
||||
/>
|
||||
<body
|
||||
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : 'light'}`}
|
||||
>
|
||||
<AppRouterCacheProvider>
|
||||
<ThemeStoreProvider themeMode={themeMode}>
|
||||
<StoreProvider
|
||||
kbDetail={kbDetail}
|
||||
themeMode={themeMode || 'light'}
|
||||
mobile={isMobile}
|
||||
authInfo={authInfo}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
height: error ? '100vh' : 'auto',
|
||||
}}
|
||||
id='app-theme-root'
|
||||
>
|
||||
{error ? <ErrorComponent error={error} /> : children}
|
||||
</Box>
|
||||
</StoreProvider>
|
||||
</ThemeStoreProvider>
|
||||
</AppRouterCacheProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
1242
web/app/src/app/markdown.css
Normal file
48
web/app/src/app/not-found.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import notFound from '@/assets/images/404.png';
|
||||
import { FooterProvider } from '@/components/footer';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
pt: 28,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxWidth: 1200,
|
||||
overflow: 'auto',
|
||||
pb: 6,
|
||||
mx: 'auto',
|
||||
}}
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
>
|
||||
<Image src={notFound} alt='404' width={380} height={200} />
|
||||
<Stack
|
||||
gap={3}
|
||||
alignItems='center'
|
||||
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
|
||||
>
|
||||
页面不存在
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
height: 40,
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<FooterProvider showBrand={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
28
web/app/src/app/widget/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import StoreProvider from '@/provider';
|
||||
import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
|
||||
import { darkThemeWidget, lightThemeWidget } from '@/theme';
|
||||
import { ThemeProvider } from '@ctzhian/ui';
|
||||
import React from 'react';
|
||||
|
||||
const Layout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const widgetDetail: any = await getShareV1AppWidgetInfo();
|
||||
const themeMode = widgetDetail?.settings?.widget_bot_settings?.theme_mode;
|
||||
|
||||
let selectedTheme = lightThemeWidget;
|
||||
|
||||
if (themeMode === 'dark') {
|
||||
selectedTheme = darkThemeWidget;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={selectedTheme}>
|
||||
<StoreProvider widget={widgetDetail}>{children}</StoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
3
web/app/src/app/widget/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Widget from '@/views/widget';
|
||||
|
||||
export default Widget;
|
||||
BIN
web/app/src/assets/fonts/AlibabaPuHuiTi-Bold.ttf
Normal file
BIN
web/app/src/assets/fonts/AlibabaPuHuiTi-Regular.ttf
Normal file
BIN
web/app/src/assets/fonts/gilroy-bold-700.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-light-300.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-medium-500.otf
Normal file
BIN
web/app/src/assets/fonts/gilroy-regular-400.otf
Normal file
BIN
web/app/src/assets/images/404.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
web/app/src/assets/images/500.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
web/app/src/assets/images/ai-loading.gif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
web/app/src/assets/images/answer.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
web/app/src/assets/images/banner-bg.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
web/app/src/assets/images/block.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
web/app/src/assets/images/dark-bgi.png
Normal file
|
After Width: | Height: | Size: 619 KiB |
BIN
web/app/src/assets/images/dot.png
Normal file
|
After Width: | Height: | Size: 268 B |
BIN
web/app/src/assets/images/feedback.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
web/app/src/assets/images/footer-logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
web/app/src/assets/images/light-bgi.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
web/app/src/assets/images/loading.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
web/app/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/app/src/assets/images/no-doc.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
web/app/src/assets/images/no-permission.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
web/app/src/assets/images/nodata.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
206
web/app/src/assets/type/index.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ConstsCopySetting,
|
||||
ConstsWatermarkSetting,
|
||||
DomainDisclaimerSettings,
|
||||
DomainConversationSetting,
|
||||
DomainWebAppLandingConfig,
|
||||
} from '@/request/types';
|
||||
|
||||
export interface NavBtn {
|
||||
id: string;
|
||||
url: string;
|
||||
variant: 'contained' | 'outlined';
|
||||
showIcon: boolean;
|
||||
icon: string;
|
||||
text: string;
|
||||
target: '_blank' | '_self';
|
||||
}
|
||||
|
||||
export interface Heading {
|
||||
id: string;
|
||||
title: string;
|
||||
heading: number;
|
||||
}
|
||||
|
||||
export interface FooterSetting {
|
||||
footer_style: 'simple' | 'complex';
|
||||
corp_name: string;
|
||||
icp: string;
|
||||
brand_name: string;
|
||||
brand_desc: string;
|
||||
brand_logo: string;
|
||||
brand_groups: BrandGroup[];
|
||||
}
|
||||
|
||||
export interface BrandGroup {
|
||||
name: string;
|
||||
links: {
|
||||
name: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AuthSetting {
|
||||
enabled: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CatalogSetting {
|
||||
catalog_visible: 1 | 2;
|
||||
catalog_folder: 1 | 2;
|
||||
catalog_width: number;
|
||||
}
|
||||
|
||||
export interface ThemeAndStyleSetting {
|
||||
bg_image: string;
|
||||
doc_width: string;
|
||||
}
|
||||
|
||||
export interface KBDetail {
|
||||
name: string;
|
||||
base_url?: string;
|
||||
settings: {
|
||||
conversation_setting: DomainConversationSetting;
|
||||
title: string;
|
||||
btns: NavBtn[];
|
||||
icon: string;
|
||||
welcome_str: string;
|
||||
search_placeholder: string;
|
||||
recommend_questions: string[];
|
||||
recommend_node_ids: string[];
|
||||
desc: string;
|
||||
keyword: string;
|
||||
head_code: string;
|
||||
body_code: string;
|
||||
theme_mode?: 'light' | 'dark';
|
||||
simple_auth?: AuthSetting | null;
|
||||
footer_settings?: FooterSetting | null;
|
||||
catalog_settings?: CatalogSetting | null;
|
||||
theme_and_style?: ThemeAndStyleSetting | null;
|
||||
watermark_content?: string;
|
||||
watermark_setting?: ConstsWatermarkSetting;
|
||||
copy_setting?: ConstsCopySetting;
|
||||
disclaimer_settings?: DomainDisclaimerSettings;
|
||||
web_app_custom_style: {
|
||||
allow_theme_switching?: boolean;
|
||||
header_search_placeholder?: string;
|
||||
show_brand_info?: boolean;
|
||||
social_media_accounts?: DomainSocialMediaAccount[];
|
||||
footer_show_intro?: boolean;
|
||||
};
|
||||
contribute_settings?: {
|
||||
is_enable: boolean;
|
||||
};
|
||||
web_app_landing_configs: DomainWebAppLandingConfig[];
|
||||
};
|
||||
}
|
||||
export interface DomainSocialMediaAccount {
|
||||
channel?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
text?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export type WidgetInfo = {
|
||||
recommend_nodes: RecommendNode[];
|
||||
settings: {
|
||||
title: string;
|
||||
icon: string;
|
||||
welcome_str: string;
|
||||
search_placeholder: string;
|
||||
recommend_questions: string[];
|
||||
widget_bot_settings: {
|
||||
btn_logo?: string;
|
||||
btn_text?: string;
|
||||
btn_style?: string;
|
||||
btn_id?: string;
|
||||
btn_position?: string;
|
||||
modal_position?: string;
|
||||
is_open?: boolean;
|
||||
recommend_node_ids?: string[];
|
||||
recommend_questions?: string[];
|
||||
theme_mode?: string;
|
||||
search_mode?: string;
|
||||
placeholder?: string;
|
||||
disclaimer?: string;
|
||||
copyright_hide_enabled?: boolean;
|
||||
copyright_info?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type RecommendNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 1 | 2;
|
||||
emoji: string;
|
||||
parent_id: string;
|
||||
summary: string;
|
||||
position: number;
|
||||
recommend_nodes?: RecommendNode[];
|
||||
};
|
||||
|
||||
export interface NodeDetail {
|
||||
id: string;
|
||||
kb_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
type: 1 | 2;
|
||||
creator_account: string;
|
||||
editor_account: string;
|
||||
meta: {
|
||||
doc_width: string;
|
||||
summary: string;
|
||||
emoji?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 1 | 2;
|
||||
emoji: string;
|
||||
position: number;
|
||||
parent_id: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 1 | 2; // 1 草稿 2 发布
|
||||
}
|
||||
|
||||
export interface ChunkResultItem {
|
||||
node_id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ITreeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
order?: number;
|
||||
emoji?: string;
|
||||
defaultExpand?: boolean;
|
||||
expanded?: boolean;
|
||||
parentId?: string | null;
|
||||
summary?: string;
|
||||
children?: ITreeItem[];
|
||||
type: 1 | 2;
|
||||
isEditting?: boolean;
|
||||
canHaveChildren?: boolean;
|
||||
updated_at?: string;
|
||||
status?: 1 | 2;
|
||||
}
|
||||
|
||||
export interface ConversationItem {
|
||||
q: string;
|
||||
a: string;
|
||||
score: number;
|
||||
update_time: string;
|
||||
message_id: string;
|
||||
source: 'history' | 'chat';
|
||||
chunk_result: ChunkResultItem[];
|
||||
}
|
||||
1238
web/app/src/components/QaModal/AiQaContent.tsx
Normal file
436
web/app/src/components/QaModal/SearchDocContent.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
import Logo from '@/assets/images/logo.png';
|
||||
import noDocImage from '@/assets/images/no-doc.png';
|
||||
import { useBasePath } from '@/hooks';
|
||||
import { useStore } from '@/provider';
|
||||
import { postShareV1ChatSearch } from '@/request/ShareChatSearch';
|
||||
import { DomainNodeContentChunkSSE } from '@/request/types';
|
||||
import { getImagePath } from '@/utils/getImagePath';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Skeleton,
|
||||
Stack,
|
||||
styled,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
IconFasong,
|
||||
IconJinsousuo,
|
||||
IconMianbaoxie,
|
||||
IconWenjian,
|
||||
} from '@panda-wiki/icons';
|
||||
import Image from 'next/image';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const StyledSearchResultItem = styled(Stack)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
borderBottom: '1px dashed',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
borderBottom: '1px dashed',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.02),
|
||||
'.hover-primary': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SearchDocSkeleton = () => {
|
||||
return (
|
||||
<StyledSearchResultItem>
|
||||
<Stack gap={1}>
|
||||
<Skeleton variant='rounded' height={16} width={200} />
|
||||
<Skeleton variant='rounded' height={22} width={400} />
|
||||
<Skeleton variant='rounded' height={16} width={500} />
|
||||
</Stack>
|
||||
</StyledSearchResultItem>
|
||||
);
|
||||
};
|
||||
interface SearchDocContentProps {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SearchDocContent: React.FC<SearchDocContentProps> = ({
|
||||
inputRef,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { kbDetail } = useStore();
|
||||
const basePath = useBasePath();
|
||||
// 模糊搜索相关状态
|
||||
const [fuzzySuggestions, setFuzzySuggestions] = useState<string[]>([]);
|
||||
const [showFuzzySuggestions, setShowFuzzySuggestions] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [hasSearch, setHasSearch] = useState(false);
|
||||
// 搜索结果相关状态
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
DomainNodeContentChunkSSE[]
|
||||
>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// 处理输入变化,显示模糊搜索建议
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setInput(value);
|
||||
|
||||
// if (value.trim().length > 0) {
|
||||
// // 改进的模糊搜索逻辑
|
||||
// const filtered = mockFuzzySuggestions
|
||||
// .filter(suggestion => {
|
||||
// const lowerSuggestion = suggestion.toLowerCase();
|
||||
// const lowerValue = value.toLowerCase();
|
||||
// // 支持前缀匹配和包含匹配
|
||||
// return (
|
||||
// lowerSuggestion.startsWith(lowerValue) ||
|
||||
// lowerSuggestion.includes(lowerValue)
|
||||
// );
|
||||
// })
|
||||
// .slice(0, 5); // 限制显示数量
|
||||
|
||||
// setFuzzySuggestions(filtered);
|
||||
// setShowFuzzySuggestions(true);
|
||||
// } else {
|
||||
// setShowFuzzySuggestions(false);
|
||||
// setFuzzySuggestions([]);
|
||||
// }
|
||||
};
|
||||
|
||||
// 选择模糊搜索建议
|
||||
const handleFuzzySuggestionClick = (suggestion: string) => {
|
||||
setInput(suggestion);
|
||||
setShowFuzzySuggestions(false);
|
||||
setFuzzySuggestions([]);
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = async () => {
|
||||
if (isSearching) return;
|
||||
if (!input.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
setShowFuzzySuggestions(false);
|
||||
setFuzzySuggestions([]);
|
||||
|
||||
let token = '';
|
||||
const Cap = (await import(`@cap.js/widget`)).default;
|
||||
const cap = new Cap({
|
||||
apiEndpoint: `${basePath}/share/v1/captcha/`,
|
||||
});
|
||||
try {
|
||||
const solution = await cap.solve();
|
||||
token = solution.token;
|
||||
} catch (error) {
|
||||
message.error('验证失败');
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
postShareV1ChatSearch({ message: input, captcha_token: token })
|
||||
.then(res => {
|
||||
setSearchResults(res.node_result || []);
|
||||
setHasSearch(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSearching(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 处理搜索结果点击
|
||||
const handleSearchResultClick = (result: DomainNodeContentChunkSSE) => {
|
||||
window.open(`${basePath}/node/${result.node_id}`, '_blank');
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 高亮显示匹配的文本
|
||||
const highlightMatch = (text: string, query: string) => {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
// 转义特殊字符,避免正则表达式错误
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// 检查是否匹配(不区分大小写)
|
||||
if (part.toLowerCase() === query.toLowerCase()) {
|
||||
return (
|
||||
<Box
|
||||
component='span'
|
||||
key={index}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
gap={2}
|
||||
sx={{ mb: 3, mt: 1 }}
|
||||
>
|
||||
<Image
|
||||
src={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
|
||||
alt='logo'
|
||||
width={46}
|
||||
height={46}
|
||||
unoptimized
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant='h6'
|
||||
sx={{ fontSize: 32, color: 'text.primary', fontWeight: 700 }}
|
||||
>
|
||||
{kbDetail?.settings?.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* 搜索输入框 */}
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
fullWidth
|
||||
autoFocus
|
||||
sx={theme => ({
|
||||
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
borderRadius: 2,
|
||||
'& .MuiInputBase-root': {
|
||||
fontSize: 16,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
'& fieldset': {
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: `${theme.palette.primary.main} !important`,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
py: 1.5,
|
||||
},
|
||||
})}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
<IconJinsousuo sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handleSearch}
|
||||
disabled={!input.trim() || isSearching}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': { bgcolor: 'primary.lighter' },
|
||||
'&.Mui-disabled': { color: 'action.disabled' },
|
||||
}}
|
||||
>
|
||||
{isSearching ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<IconFasong
|
||||
sx={{
|
||||
fontSize: 22,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* 模糊搜索建议列表 */}
|
||||
{showFuzzySuggestions && fuzzySuggestions.length > 0 && (
|
||||
<Stack
|
||||
sx={{
|
||||
mt: 1,
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
gap={0.5}
|
||||
>
|
||||
{fuzzySuggestions.map((suggestion, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
onClick={() => handleFuzzySuggestionClick(suggestion)}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 2,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{highlightMatch(suggestion, input)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{/* 搜索结果统计 */}
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 2,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
共找到 {searchResults.length} 个结果
|
||||
</Typography>
|
||||
|
||||
{/* 搜索结果列表 */}
|
||||
<Stack sx={{ overflow: 'auto', maxHeight: 'calc(100vh - 334px)' }}>
|
||||
{searchResults.map((result, index) => (
|
||||
<StyledSearchResultItem
|
||||
direction='row'
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
key={result.node_id}
|
||||
gap={2}
|
||||
onClick={() => handleSearchResultClick(result)}
|
||||
>
|
||||
<Stack sx={{ flex: 1, width: 0 }} gap={0.5}>
|
||||
{/* 路径 */}
|
||||
<Typography
|
||||
variant='caption'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontSize: 12,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{(result.node_path_names || []).length > 0
|
||||
? result.node_path_names?.join(' > ')
|
||||
: result.name}
|
||||
</Typography>
|
||||
|
||||
{/* 标题和图标 */}
|
||||
|
||||
<Typography
|
||||
variant='h6'
|
||||
className='hover-primary'
|
||||
sx={{
|
||||
gap: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{result.emoji || <IconWenjian />} {result.name}
|
||||
</Typography>
|
||||
|
||||
{/* 描述 */}
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{result.summary || '暂无摘要'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconMianbaoxie sx={{ fontSize: 12 }} />
|
||||
</StyledSearchResultItem>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{searchResults.length === 0 && !isSearching && hasSearch && (
|
||||
<Box sx={{ my: 5, textAlign: 'center' }}>
|
||||
<Image src={noDocImage} alt='暂无结果' width={250} />
|
||||
<Typography variant='body2' sx={{ color: 'text.tertiary' }}>
|
||||
暂无相关结果
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 搜索中状态 */}
|
||||
{isSearching && (
|
||||
<Stack sx={{ mt: 2 }}>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<SearchDocSkeleton key={index} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDocContent;
|
||||
344
web/app/src/components/QaModal/StyledComponents.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
styled,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
|
||||
// 布局容器组件
|
||||
export const StyledMainContainer = styled(Box)(() => ({
|
||||
flex: 1,
|
||||
}));
|
||||
|
||||
export const StyledConversationContainer = styled(Stack)(() => ({
|
||||
maxHeight: 'calc(100vh - 332px)',
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledConversationItem = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
// 聊天气泡相关组件
|
||||
export const StyledUserBubble = styled(Box)(({ theme }) => ({
|
||||
alignSelf: 'flex-end',
|
||||
maxWidth: '75%',
|
||||
padding: theme.spacing(1, 2),
|
||||
borderRadius: '10px 10px 0px 10px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontSize: 14,
|
||||
wordBreak: 'break-word',
|
||||
}));
|
||||
|
||||
export const StyledAiBubble = styled(Box)(({ theme }) => ({
|
||||
alignSelf: 'flex-start',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: theme.spacing(3),
|
||||
}));
|
||||
|
||||
export const StyledAiBubbleContent = styled(Box)(() => ({
|
||||
wordBreak: 'break-word',
|
||||
}));
|
||||
|
||||
// 对话相关组件
|
||||
export const StyledAccordion = styled(Accordion)(() => ({
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
'&:before': {
|
||||
content: '""',
|
||||
height: 0,
|
||||
},
|
||||
background: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
}));
|
||||
|
||||
export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
userSelect: 'text',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: theme.palette.background.paper3,
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
}));
|
||||
|
||||
export const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
|
||||
padding: theme.spacing(2),
|
||||
borderTop: 'none',
|
||||
}));
|
||||
|
||||
export const StyledQuestionText = styled(Box)(() => ({
|
||||
fontWeight: '700',
|
||||
fontSize: 16,
|
||||
lineHeight: '24px',
|
||||
wordBreak: 'break-all',
|
||||
}));
|
||||
|
||||
// 搜索结果相关组件
|
||||
export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({
|
||||
backgroundImage: 'none',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}));
|
||||
|
||||
export const StyledChunkAccordionSummary = styled(AccordionSummary)(
|
||||
({ theme }) => ({
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing(2),
|
||||
'.MuiAccordionSummary-content': {
|
||||
flexGrow: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const StyledChunkAccordionDetails = styled(AccordionDetails)(
|
||||
({ theme }) => ({
|
||||
paddingTop: 0,
|
||||
paddingLeft: theme.spacing(2),
|
||||
borderTop: 'none',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
}),
|
||||
);
|
||||
|
||||
export const StyledChunkItem = styled(Box)(({ theme }) => ({
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
'.hover-primary': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 思考过程相关组件
|
||||
export const StyledThinkingAccordion = styled(Accordion)(({ theme }) => ({
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
paddingBottom: theme.spacing(2),
|
||||
'&:before': {
|
||||
content: '""',
|
||||
height: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledThinkingAccordionSummary = styled(AccordionSummary)(
|
||||
({ theme }) => ({
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing(2),
|
||||
'.MuiAccordionSummary-content': {
|
||||
flexGrow: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const StyledThinkingAccordionDetails = styled(AccordionDetails)(
|
||||
({ theme }) => ({
|
||||
paddingTop: 0,
|
||||
paddingLeft: theme.spacing(2),
|
||||
borderTop: 'none',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
'.markdown-body': {
|
||||
opacity: 0.75,
|
||||
fontSize: 12,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 操作区域组件
|
||||
export const StyledActionStack = styled(Stack)(({ theme }) => ({
|
||||
fontSize: 12,
|
||||
color: alpha(theme.palette.text.primary, 0.35),
|
||||
}));
|
||||
|
||||
// 输入区域组件
|
||||
export const StyledInputContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
export const StyledInputWrapper = styled(Stack)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(1.5),
|
||||
paddingRight: theme.spacing(1.5),
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing(2),
|
||||
backgroundColor: theme.palette.background.default,
|
||||
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
transition: 'border-color 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
'&:focus-within': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
// 图片预览组件
|
||||
export const StyledImagePreviewStack = styled(Stack)(() => ({
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
export const StyledImagePreviewItem = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
}));
|
||||
|
||||
export const StyledImageRemoveButton = styled(IconButton)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
}));
|
||||
|
||||
// 输入框组件
|
||||
export const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.default,
|
||||
'.MuiInputBase-root': {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
height: '52px !important',
|
||||
},
|
||||
textarea: {
|
||||
borderRadius: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
padding: '2px',
|
||||
},
|
||||
fieldset: {
|
||||
border: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
// 操作按钮组件
|
||||
export const StyledActionButtonStack = styled(Stack)(() => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
// 搜索建议组件
|
||||
export const StyledFuzzySuggestionsStack = styled(Stack)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1),
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
}));
|
||||
|
||||
export const StyledFuzzySuggestionItem = styled(Box)(({ theme }) => ({
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
}));
|
||||
|
||||
// 热门搜索组件
|
||||
export const StyledHotSearchStack = styled(Stack)(({ theme }) => ({
|
||||
marginTop: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const StyledHotSearchItem = styled(Box)(({ theme }) => ({
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
marginBottom: theme.spacing(1),
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.02),
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.01)}`,
|
||||
color: alpha(theme.palette.text.primary, 0.75),
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
alignSelf: 'flex-start',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
}));
|
||||
|
||||
// 热门搜索容器
|
||||
export const StyledHotSearchContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
// 热门搜索列
|
||||
export const StyledHotSearchColumn = styled(Box)(({ theme }) => ({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
borderLeft: `1px solid ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
}));
|
||||
|
||||
// 热门搜索列项目
|
||||
export const StyledHotSearchColumnItem = styled(Box)(({ theme }) => ({
|
||||
paddingRight: theme.spacing(2),
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
15
web/app/src/components/QaModal/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// 常量定义
|
||||
export const MAX_IMAGES = 9;
|
||||
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
export const CONVERSATION_MAX_HEIGHT = 'calc(100vh - 334px)';
|
||||
export const FUZZY_SUGGESTIONS_LIMIT = 5;
|
||||
|
||||
// 回答状态
|
||||
export const AnswerStatus = {
|
||||
1: '正在搜索结果...',
|
||||
2: '思考中...',
|
||||
3: '正在回答',
|
||||
4: '',
|
||||
} as const;
|
||||
|
||||
export type AnswerStatusType = keyof typeof AnswerStatus;
|
||||
282
web/app/src/components/QaModal/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { IconZhinengwenda, IconJinsousuo } from '@panda-wiki/icons';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Modal,
|
||||
Stack,
|
||||
lighten,
|
||||
alpha,
|
||||
styled,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import AiQaContent from './AiQaContent';
|
||||
import SearchDocContent from './SearchDocContent';
|
||||
import { useStore } from '@/provider';
|
||||
|
||||
interface SearchSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: 'recent' | 'suggestion' | 'trending';
|
||||
}
|
||||
|
||||
interface QaModalProps {
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
onSearch?: (value?: string, type?: 'search' | 'chat') => void;
|
||||
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
|
||||
defaultSuggestions?: SearchSuggestion[];
|
||||
}
|
||||
|
||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
position: 'relative',
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(0.5),
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
'& .MuiTabs-indicator': {
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
zIndex: 0,
|
||||
},
|
||||
'& .MuiTabs-flexContainer': {
|
||||
gap: theme.spacing(0.5),
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
// 样式化的 Tab 组件 - 白色背景,圆角,深灰色文字
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
padding: theme.spacing(0.75, 2),
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
textTransform: 'none',
|
||||
transition: 'color 0.3s ease-in-out',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
lineHeight: 1,
|
||||
'&:hover': {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
const QaModal: React.FC<QaModalProps> = () => {
|
||||
const { qaModalOpen, setQaModalOpen, kbDetail, mobile } = useStore();
|
||||
const [searchMode, setSearchMode] = useState<'chat' | 'search'>('chat');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const aiQaInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const onClose = () => {
|
||||
setQaModalOpen?.(false);
|
||||
};
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
return (
|
||||
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder ||
|
||||
'搜索...'
|
||||
);
|
||||
}, [kbDetail]);
|
||||
|
||||
const hotSearch = useMemo(() => {
|
||||
const bannerConfig = kbDetail?.settings?.web_app_landing_configs?.find(
|
||||
item => item.type === 'banner',
|
||||
);
|
||||
return bannerConfig?.banner_config?.hot_search || [];
|
||||
}, [kbDetail]);
|
||||
|
||||
// modal打开时自动聚焦
|
||||
useEffect(() => {
|
||||
if (qaModalOpen) {
|
||||
setTimeout(() => {
|
||||
if (searchMode === 'chat') {
|
||||
aiQaInputRef.current?.querySelector('textarea')?.focus();
|
||||
} else {
|
||||
inputRef.current?.querySelector('input')?.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [qaModalOpen, searchMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qaModalOpen) {
|
||||
setTimeout(() => {
|
||||
setSearchMode('chat');
|
||||
}, 300);
|
||||
}
|
||||
}, [qaModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const cid = searchParams.get('cid');
|
||||
const ask = searchParams.get('ask');
|
||||
if (cid || ask) {
|
||||
setQaModalOpen?.(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={qaModalOpen as boolean}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
maxWidth: 800,
|
||||
maxHeight: '100%',
|
||||
backgroundColor: lighten(theme.palette.background.default, 0.05),
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
||||
overflow: 'hidden',
|
||||
outline: 'none',
|
||||
pb: 2,
|
||||
})}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部标签栏 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 2.5,
|
||||
}}
|
||||
>
|
||||
<StyledTabs
|
||||
value={searchMode}
|
||||
onChange={(_, value) => {
|
||||
setSearchMode(value as 'chat' | 'search');
|
||||
}}
|
||||
variant='scrollable'
|
||||
scrollButtons={false}
|
||||
>
|
||||
<StyledTab
|
||||
label={
|
||||
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||
<IconZhinengwenda sx={{ fontSize: 16 }} />
|
||||
{!mobile && <span>智能问答</span>}
|
||||
</Stack>
|
||||
}
|
||||
value='chat'
|
||||
/>
|
||||
<StyledTab
|
||||
label={
|
||||
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||
<IconJinsousuo sx={{ fontSize: 16 }} />
|
||||
{!mobile && <span>仅搜索文档</span>}
|
||||
</Stack>
|
||||
}
|
||||
value='search'
|
||||
/>
|
||||
</StyledTabs>
|
||||
|
||||
{/* Esc按钮 */}
|
||||
{!mobile && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={onClose}
|
||||
size='small'
|
||||
sx={theme => ({
|
||||
minWidth: 'auto',
|
||||
px: 1,
|
||||
py: '1px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
color: 'text.secondary',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 主内容区域 - 根据模式切换 */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
flex: 1,
|
||||
display: searchMode === 'chat' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<AiQaContent
|
||||
hotSearch={hotSearch}
|
||||
placeholder={placeholder}
|
||||
inputRef={aiQaInputRef}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
flex: 1,
|
||||
display: searchMode === 'search' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<SearchDocContent inputRef={inputRef} placeholder={placeholder} />
|
||||
</Box>
|
||||
|
||||
{/* 底部AI生成提示 */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
pt: !kbDetail?.settings?.conversation_setting
|
||||
?.copyright_hide_enabled
|
||||
? 2
|
||||
: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='caption'
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{!kbDetail?.settings?.conversation_setting
|
||||
?.copyright_hide_enabled &&
|
||||
(kbDetail?.settings?.conversation_setting?.copyright_info ||
|
||||
'本网站由 PandaWiki 提供技术支持')}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QaModal;
|
||||
32
web/app/src/components/QaModal/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChunkResultItem } from '@/assets/type';
|
||||
|
||||
export interface ConversationItem {
|
||||
q: string;
|
||||
a: string;
|
||||
score: number;
|
||||
update_time: string;
|
||||
message_id: string;
|
||||
source: 'history' | 'chat';
|
||||
chunk_result: ChunkResultItem[];
|
||||
thinking_content: string;
|
||||
}
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface SSEMessageData {
|
||||
type: string;
|
||||
content: string;
|
||||
chunk_result: ChunkResultItem;
|
||||
}
|
||||
|
||||
export interface ChatRequestData {
|
||||
message: string;
|
||||
nonce: string;
|
||||
conversation_id: string;
|
||||
app_type: number;
|
||||
captcha_token: string;
|
||||
}
|
||||
16
web/app/src/components/QaModal/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const handleThinkingContent = (content: string) => {
|
||||
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
|
||||
const thinkMatches = [];
|
||||
let match;
|
||||
while ((match = thinkRegex.exec(content)) !== null) {
|
||||
thinkMatches.push(match[1]);
|
||||
}
|
||||
|
||||
let answerContent = content.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||
answerContent = answerContent.replace(/<think>[\s\S]*$/, '');
|
||||
|
||||
return {
|
||||
thinkingContent: thinkMatches.join(''),
|
||||
answerContent: answerContent,
|
||||
};
|
||||
};
|
||||
448
web/app/src/components/commentInput/index.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
'use client';
|
||||
|
||||
import { useBasePath } from '@/hooks';
|
||||
import { postShareV1CommonFileUpload } from '@/request/ShareFile';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
IconButton,
|
||||
Popover,
|
||||
Stack,
|
||||
TextField,
|
||||
TextFieldProps,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import zh from '../emoji/emoji-data/zh.json';
|
||||
|
||||
export interface ImageItem {
|
||||
id: string;
|
||||
url: string; // 本地预览 URL (blob URL)
|
||||
file: File;
|
||||
uploaded?: boolean; // 是否已上传到服务器
|
||||
uploadedUrl?: string; // 上传后的服务器 URL
|
||||
}
|
||||
|
||||
interface CommentInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onImagesChange?: (images: ImageItem[]) => void;
|
||||
placeholder?: string;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
maxImages?: number;
|
||||
textFieldProps?: Partial<TextFieldProps>;
|
||||
}
|
||||
|
||||
export interface CommentInputRef {
|
||||
uploadImages: () => Promise<string[]>; // 上传所有图片并返回 URL 列表
|
||||
clearImages: () => void; // 清空图片
|
||||
}
|
||||
|
||||
const CommentInput = React.forwardRef<CommentInputRef, CommentInputProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onImagesChange,
|
||||
placeholder = '请输入评论',
|
||||
error,
|
||||
helperText,
|
||||
onFocus,
|
||||
onBlur,
|
||||
maxImages = 9,
|
||||
textFieldProps,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const basePath = useBasePath();
|
||||
const [images, setImages] = useState<ImageItem[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [emojiAnchorEl, setEmojiAnchorEl] =
|
||||
useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// 添加本地图片预览(不上传到服务器)
|
||||
const handleImageSelect = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const remainingSlots = maxImages - images.length;
|
||||
if (remainingSlots <= 0) {
|
||||
message.warning(`最多只能上传 ${maxImages} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToAdd = Array.from(files).slice(0, remainingSlots);
|
||||
|
||||
try {
|
||||
const newImages: ImageItem[] = [];
|
||||
|
||||
for (const file of filesToAdd) {
|
||||
// 验证文件类型(只允许 jpg、jpeg、png、webp)
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
message.error('只支持上传 jpg、jpeg、png、webp 格式的图片');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证文件大小 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 10MB');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建本地预览 URL
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
|
||||
newImages.push({
|
||||
id: Date.now().toString() + Math.random(),
|
||||
url: localUrl,
|
||||
file,
|
||||
uploaded: false,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedImages = [...images, ...newImages];
|
||||
setImages(updatedImages);
|
||||
onImagesChange?.(updatedImages);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '图片选择失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 上传所有图片到服务器
|
||||
const uploadAllImages = async (): Promise<string[]> => {
|
||||
if (images.length === 0) return [];
|
||||
|
||||
setUploading(true);
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
try {
|
||||
for (const image of images) {
|
||||
if (image.uploaded && image.uploadedUrl) {
|
||||
// 已经上传过的图片直接使用服务器 URL
|
||||
uploadedUrls.push(image.uploadedUrl);
|
||||
} else {
|
||||
let token = '';
|
||||
|
||||
try {
|
||||
const Cap = (await import(`@cap.js/widget`)).default;
|
||||
const cap = new Cap({
|
||||
apiEndpoint: `${basePath}/share/v1/captcha/`,
|
||||
});
|
||||
const solution = await cap.solve();
|
||||
token = solution.token;
|
||||
} catch (error) {
|
||||
message.error('验证失败');
|
||||
setUploading(false);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// 上传新图片
|
||||
const result = await postShareV1CommonFileUpload({
|
||||
file: image.file,
|
||||
captcha_token: token,
|
||||
});
|
||||
const serverUrl = '/static-file/' + result.key;
|
||||
uploadedUrls.push(serverUrl);
|
||||
|
||||
// 更新图片状态
|
||||
image.uploaded = true;
|
||||
image.uploadedUrl = serverUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedUrls;
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '图片上传失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 清空所有图片
|
||||
const clearImages = () => {
|
||||
// 释放所有本地 URL
|
||||
images.forEach(img => {
|
||||
if (!img.uploaded && img.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(img.url);
|
||||
}
|
||||
});
|
||||
setImages([]);
|
||||
onImagesChange?.([]);
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
uploadImages: uploadAllImages,
|
||||
clearImages,
|
||||
}));
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
const dataTransfer = new DataTransfer();
|
||||
imageFiles.forEach(file => dataTransfer.items.add(file));
|
||||
await handleImageSelect(dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleImageSelect(e.target.files);
|
||||
// 重置 input value 以允许上传相同文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (id: string) => {
|
||||
const imageToRemove = images.find(img => img.id === id);
|
||||
if (
|
||||
imageToRemove &&
|
||||
!imageToRemove.uploaded &&
|
||||
imageToRemove.url.startsWith('blob:')
|
||||
) {
|
||||
// 释放本地 URL
|
||||
URL.revokeObjectURL(imageToRemove.url);
|
||||
}
|
||||
|
||||
const updatedImages = images.filter(img => img.id !== id);
|
||||
setImages(updatedImages);
|
||||
onImagesChange?.(updatedImages);
|
||||
};
|
||||
|
||||
const handleClickUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleEmojiClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setEmojiAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleEmojiClose = () => {
|
||||
setEmojiAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji: any) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
const start = input.selectionStart || 0;
|
||||
const end = input.selectionEnd || 0;
|
||||
const newValue =
|
||||
value.substring(0, start) + emoji.native + value.substring(end);
|
||||
onChange(newValue);
|
||||
|
||||
// 将光标移动到插入的表情后面
|
||||
setTimeout(() => {
|
||||
const newPosition = start + emoji.native.length;
|
||||
input.setSelectionRange(newPosition, newPosition);
|
||||
input.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
// 如果无法获取光标位置,就追加到末尾
|
||||
onChange(value + emoji.native);
|
||||
}
|
||||
handleEmojiClose();
|
||||
};
|
||||
|
||||
const emojiOpen = Boolean(emojiAnchorEl);
|
||||
const emojiPopoverId = emojiOpen ? 'emoji-popover' : undefined;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
inputRef={inputRef}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
maxLength: 1000,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'.MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
},
|
||||
'.MuiInputBase-root': {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
{...textFieldProps}
|
||||
/>
|
||||
|
||||
{/* 图片预览区域 */}
|
||||
{images.length > 0 && (
|
||||
<Stack direction='row' flexWrap='wrap' gap={1} sx={{ mt: 2, mb: 1 }}>
|
||||
{images.map(image => (
|
||||
<Box
|
||||
key={image.id}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover .delete-btn': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={image.url}
|
||||
alt='preview'
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
className='delete-btn'
|
||||
size='small'
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
bgcolor: theme => alpha(theme.palette.common.black, 0.6),
|
||||
color: 'white',
|
||||
// opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: theme => alpha(theme.palette.common.black, 0.8),
|
||||
},
|
||||
width: 20,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* 底部工具栏 */}
|
||||
<Stack direction='row' alignItems='center' gap={0.5} sx={{ mt: 1 }}>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handleEmojiClick}
|
||||
aria-describedby={emojiPopoverId}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InsertEmoticonIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handleClickUpload}
|
||||
disabled={uploading || images.length >= maxImages}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ImageIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
fontSize: 12,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
{value.length} / 1000
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.jpg,.jpeg,.png,.webp'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
{/* 表情选择器 Popover */}
|
||||
<Popover
|
||||
id={emojiPopoverId}
|
||||
open={emojiOpen}
|
||||
anchorEl={emojiAnchorEl}
|
||||
onClose={handleEmojiClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
set='native'
|
||||
theme={theme.palette.mode === 'dark' ? 'dark' : 'light'}
|
||||
locale='zh'
|
||||
i18n={zh}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
previewPosition='none'
|
||||
searchPosition='sticky'
|
||||
skinTonePosition='none'
|
||||
perLine={9}
|
||||
emojiSize={24}
|
||||
/>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CommentInput.displayName = 'CommentInput';
|
||||
|
||||
export default CommentInput;
|
||||
133
web/app/src/components/docFab/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
import { useStore } from '@/provider';
|
||||
import { useBasePath } from '@/hooks';
|
||||
import { Modal } from '@ctzhian/ui';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import {
|
||||
Fab,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Zoom,
|
||||
} from '@mui/material';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
const DocFab = () => {
|
||||
const pathname = usePathname();
|
||||
const { id: docId } = useParams() || {};
|
||||
const { kbDetail, mobile } = useStore();
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [contentType, setContentType] = useState<'html' | 'md'>('html');
|
||||
const [openSelectContentTypeModal, setOpenSelectContentTypeModal] =
|
||||
useState(false);
|
||||
const basePath = useBasePath();
|
||||
if (mobile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title='新建文档类型'
|
||||
open={openSelectContentTypeModal}
|
||||
onCancel={() => {
|
||||
setOpenSelectContentTypeModal(false);
|
||||
setContentType('html');
|
||||
}}
|
||||
onOk={() => {
|
||||
setOpenSelectContentTypeModal(false);
|
||||
window.open(
|
||||
`${basePath}/editor?contentType=${contentType}`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RadioGroup
|
||||
value={contentType}
|
||||
onChange={e => setContentType(e.target.value as 'html' | 'md')}
|
||||
>
|
||||
<FormControlLabel
|
||||
value='html'
|
||||
control={<Radio size='small' />}
|
||||
label='富文本'
|
||||
/>
|
||||
<FormControlLabel
|
||||
value='md'
|
||||
control={<Radio size='small' />}
|
||||
label='Markdown'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Modal>
|
||||
<Stack
|
||||
gap={1}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 70,
|
||||
right: 16,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{kbDetail?.settings.contribute_settings?.is_enable && (
|
||||
<>
|
||||
<Zoom
|
||||
in={showActions}
|
||||
style={{ transitionDelay: showActions ? '100ms' : '0ms' }}
|
||||
>
|
||||
<Tooltip title='创建文档' placement='left' arrow>
|
||||
<Fab
|
||||
color='primary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setOpenSelectContentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Zoom>
|
||||
{pathname.startsWith(basePath + '/node/') && (
|
||||
<Zoom
|
||||
in={showActions}
|
||||
style={{ transitionDelay: showActions ? '40ms' : '0ms' }}
|
||||
>
|
||||
<Tooltip title='编辑文档' placement='left' arrow>
|
||||
<Fab
|
||||
color='primary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
window.open(`${basePath}/editor/${docId}`, '_blank');
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Zoom>
|
||||
)}
|
||||
<Fab
|
||||
size='small'
|
||||
sx={{
|
||||
backgroundColor: 'background.paper2',
|
||||
color: 'text.secondary',
|
||||
'&:hover': { backgroundColor: 'background.paper2' },
|
||||
}}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
>
|
||||
<MenuIcon
|
||||
sx={{
|
||||
transition: 'transform 200ms',
|
||||
transform: showActions ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</Fab>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocFab;
|
||||
58
web/app/src/components/docSkeleton/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
|
||||
interface DocSkeletonProps {
|
||||
showSummary?: boolean;
|
||||
}
|
||||
|
||||
const DocSkeleton = ({ showSummary = false }: DocSkeletonProps) => (
|
||||
<>
|
||||
<Skeleton variant='rounded' width={'70%'} height={36} sx={{ mb: '10px' }} />
|
||||
<Skeleton variant='rounded' width={'50%'} height={20} sx={{ mb: 4 }} />
|
||||
{showSummary && (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 6,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'background.paper3',
|
||||
p: '20px',
|
||||
fontSize: 14,
|
||||
lineHeight: '28px',
|
||||
backdropFilter: 'blur(5px)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontWeight: 'bold', mb: 2, lineHeight: '22px' }}>
|
||||
内容摘要
|
||||
</Box>
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' width={'30%'} height={16} />
|
||||
</Box>
|
||||
)}
|
||||
<Skeleton
|
||||
variant='rounded'
|
||||
width={'20%'}
|
||||
height={36}
|
||||
sx={{ m: '40px 0 20px' }}
|
||||
/>
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' width={'70%'} height={16} sx={{ mb: 2 }} />
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' width={'90%'} height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton
|
||||
variant='rounded'
|
||||
width={'35%'}
|
||||
height={36}
|
||||
sx={{ m: '40px 0 20px' }}
|
||||
/>
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='rounded' height={16} sx={{ mb: 1 }} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default DocSkeleton;
|
||||
29
web/app/src/components/emoji/emoji-data/zh.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"search": "搜索",
|
||||
"search_no_results_1": "哦不!",
|
||||
"search_no_results_2": "没有找到相关表情",
|
||||
"pick": "选择一个表情…",
|
||||
"add_custom": "添加自定义表情",
|
||||
"categories": {
|
||||
"activity": "活动",
|
||||
"custom": "自定义",
|
||||
"flags": "旗帜",
|
||||
"foods": "食物与饮品",
|
||||
"frequent": "最近使用",
|
||||
"nature": "动物与自然",
|
||||
"objects": "物品",
|
||||
"people": "表情与角色",
|
||||
"places": "旅行与景点",
|
||||
"search": "搜索结果",
|
||||
"symbols": "符号"
|
||||
},
|
||||
"skins": {
|
||||
"choose": "选择默认肤色",
|
||||
"1": "默认",
|
||||
"2": "白色",
|
||||
"3": "偏白",
|
||||
"4": "中等",
|
||||
"5": "偏黑",
|
||||
"6": "黑色"
|
||||
}
|
||||
}
|
||||
117
web/app/src/components/emoji/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { Box, IconButton, Popover, SxProps } from '@mui/material';
|
||||
import React, { useCallback } from 'react';
|
||||
import zh from './emoji-data/zh.json';
|
||||
import {
|
||||
IconWenjianjia,
|
||||
IconWenjianjiaKai,
|
||||
IconWenjian,
|
||||
} from '@panda-wiki/icons';
|
||||
|
||||
interface EmojiPickerProps {
|
||||
type: 1 | 2;
|
||||
readOnly?: boolean;
|
||||
value?: string;
|
||||
collapsed?: boolean;
|
||||
onChange?: (emoji: string) => void;
|
||||
sx?: SxProps;
|
||||
iconSx?: SxProps;
|
||||
}
|
||||
|
||||
const EmojiPicker: React.FC<EmojiPickerProps> = ({
|
||||
type,
|
||||
readOnly,
|
||||
value,
|
||||
onChange,
|
||||
collapsed,
|
||||
sx,
|
||||
iconSx,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (readOnly) return;
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(emoji: any) => {
|
||||
onChange?.(emoji.native);
|
||||
handleClose();
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const id = open ? 'emoji-picker' : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size='small'
|
||||
aria-describedby={id}
|
||||
disabled={readOnly}
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: 28,
|
||||
color: 'text.primary',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<Box component='span' sx={{ fontSize: 14, ...iconSx }}>
|
||||
{value}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{type === 1 ? (
|
||||
collapsed ? (
|
||||
<IconWenjianjia sx={{ fontSize: 16, ...iconSx }} />
|
||||
) : (
|
||||
<IconWenjianjiaKai sx={{ fontSize: 16, ...iconSx }} />
|
||||
)
|
||||
) : (
|
||||
<IconWenjian sx={{ fontSize: 16, ...iconSx }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
set='native'
|
||||
theme='light'
|
||||
locale='zh'
|
||||
i18n={zh}
|
||||
onEmojiSelect={handleSelect}
|
||||
previewPosition='none'
|
||||
searchPosition='sticky'
|
||||
skinTonePosition='none'
|
||||
perLine={9}
|
||||
emojiSize={24}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
30
web/app/src/components/emptyDocPlaceholder/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import noDocImage from '@/assets/images/no-doc.png';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EmptyDocPlaceholderProps {
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
const EmptyDocPlaceholder = ({ mobile = false }: EmptyDocPlaceholderProps) => (
|
||||
<Stack
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
sx={{
|
||||
flex: 1,
|
||||
pt: '50px',
|
||||
pb: 10,
|
||||
px: mobile ? 5 : 0,
|
||||
}}
|
||||
>
|
||||
<Image src={noDocImage} alt='暂无文档' width={mobile ? 280 : 380} />
|
||||
<Box sx={{ fontSize: 14, color: 'text.tertiary' }}>
|
||||
暂无文档, 请前往管理后台创建新文档
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default EmptyDocPlaceholder;
|
||||
75
web/app/src/components/error/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
import ErrorPng from '@/assets/images/500.png';
|
||||
import NoPermissionImg from '@/assets/images/no-permission.png';
|
||||
import NotFoundImg from '@/assets/images/404.png';
|
||||
import BlockImg from '@/assets/images/block.png';
|
||||
import { SxProps, Stack } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
import { useStore } from '@/provider';
|
||||
|
||||
const CODE_MAP = {
|
||||
40003: {
|
||||
title: '无权限访问',
|
||||
img: NoPermissionImg,
|
||||
},
|
||||
403: {
|
||||
title: '当前网站已关闭访问',
|
||||
img: BlockImg,
|
||||
},
|
||||
40004: {
|
||||
title: '页面不存在',
|
||||
img: NotFoundImg,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_ERROR = {
|
||||
title: '页面出错了',
|
||||
img: ErrorPng,
|
||||
};
|
||||
|
||||
export default function Error({
|
||||
sx,
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Partial<Error> & { digest?: string } & { code?: number | string };
|
||||
reset?: () => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
const { mobile } = useStore();
|
||||
const errorInfo =
|
||||
CODE_MAP[(error.code ?? error.message) as '40003'] || DEFAULT_ERROR;
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
sx={{
|
||||
height: '100%',
|
||||
...(mobile && {
|
||||
width: '100%',
|
||||
marginLeft: 0,
|
||||
}),
|
||||
...sx,
|
||||
}}
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
>
|
||||
<Image
|
||||
src={errorInfo.img.src}
|
||||
alt='404'
|
||||
width={380}
|
||||
height={255}
|
||||
style={{
|
||||
height: 'auto',
|
||||
...(mobile && { width: 200 }),
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
gap={3}
|
||||
alignItems='center'
|
||||
sx={{ color: 'text.tertiary', fontSize: 14, mt: 3 }}
|
||||
>
|
||||
{errorInfo.title}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
146
web/app/src/components/feedback/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ConversationItem } from '@/assets/type';
|
||||
import { useStore } from '@/provider';
|
||||
import { Box, Stack, TextField } from '@mui/material';
|
||||
import { Modal } from '@ctzhian/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FeedbackProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
message_id: string,
|
||||
score: number,
|
||||
type: string,
|
||||
content?: string,
|
||||
) => void;
|
||||
data: ConversationItem | { message_id: string } | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
const Feedback = ({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
data,
|
||||
tags: propsTags,
|
||||
}: FeedbackProps) => {
|
||||
const { themeMode, kbDetail } = useStore();
|
||||
const [type, setType] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const tags: string[] =
|
||||
propsTags ??
|
||||
// @ts-ignore
|
||||
(kbDetail?.settings?.ai_feedback_settings?.ai_feedback_type || []);
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent('');
|
||||
setType('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!data) return;
|
||||
onSubmit(data.message_id, -1, type, content);
|
||||
handleCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
title='反馈意见'
|
||||
cancelText='取消'
|
||||
okText='提交'
|
||||
onOk={handleSubmit}
|
||||
cancelButtonProps={{
|
||||
sx: {
|
||||
color: 'text.primary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={2}
|
||||
sx={{
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{tags.map(tag => (
|
||||
<Box
|
||||
key={tag}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
px: 2,
|
||||
fontSize: 12,
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: type === tag ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
color: type === tag ? 'primary.main' : 'text.primary',
|
||||
bgcolor:
|
||||
themeMode === 'dark'
|
||||
? 'background.paper3'
|
||||
: 'background.default',
|
||||
}}
|
||||
onClick={() => {
|
||||
setType(tag);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor:
|
||||
themeMode === 'dark' ? 'background.paper3' : 'background.default',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
size='small'
|
||||
placeholder='请输入反馈内容'
|
||||
value={content}
|
||||
sx={{
|
||||
'.MuiInputBase-root': {
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.5s ease-in-out',
|
||||
bgcolor:
|
||||
themeMode === 'dark'
|
||||
? 'background.paper3'
|
||||
: 'background.default',
|
||||
},
|
||||
textarea: {
|
||||
lineHeight: '26px',
|
||||
borderRadius: 0,
|
||||
transition: 'all 0.5s ease-in-out',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
'&::placeholder': {
|
||||
fontSize: 14,
|
||||
},
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
},
|
||||
fieldset: {
|
||||
border: 'none',
|
||||
},
|
||||
}}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Feedback;
|
||||
49
web/app/src/components/footer/Overlay.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface OverlayProps {
|
||||
open: boolean;
|
||||
onClose: Dispatch<SetStateAction<boolean>>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Overlay: React.FC<OverlayProps> = ({ open, onClose, children }) => {
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1300,
|
||||
}}
|
||||
onClick={() => onClose(false)}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => onClose(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
zIndex: 1310,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box onClick={e => e.stopPropagation()}>{children}</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
97
web/app/src/components/footer/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { useStore } from '@/provider';
|
||||
import { useMemo } from 'react';
|
||||
import { getImagePath } from '@/utils/getImagePath';
|
||||
import { useBasePath } from '@/hooks';
|
||||
|
||||
import {
|
||||
Footer,
|
||||
WelcomeFooter as WelcomeFooterComponent,
|
||||
} from '@panda-wiki/ui';
|
||||
|
||||
export const FooterProvider = ({
|
||||
showBrand = true,
|
||||
isDocPage = false,
|
||||
isWelcomePage = false,
|
||||
}: {
|
||||
showBrand?: boolean;
|
||||
isDocPage?: boolean;
|
||||
isWelcomePage?: boolean;
|
||||
}) => {
|
||||
const { mobile = false, catalogWidth, kbDetail } = useStore();
|
||||
const basePath = useBasePath();
|
||||
const docWidth = useMemo(() => {
|
||||
if (isWelcomePage) return 'full';
|
||||
return kbDetail?.settings?.theme_and_style?.doc_width || 'full';
|
||||
}, [kbDetail, isWelcomePage]);
|
||||
const footerSetting = kbDetail?.settings?.footer_settings;
|
||||
const customStyle = kbDetail?.settings?.web_app_custom_style;
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mobile={mobile}
|
||||
catalogWidth={catalogWidth}
|
||||
showBrand={showBrand}
|
||||
isDocPage={isDocPage}
|
||||
logo='https://release.baizhi.cloud/panda-wiki/icon.png'
|
||||
docWidth={docWidth}
|
||||
footerSetting={
|
||||
footerSetting
|
||||
? {
|
||||
...footerSetting,
|
||||
brand_logo: getImagePath(footerSetting?.brand_logo, basePath),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
customStyle={{
|
||||
...customStyle,
|
||||
social_media_accounts: customStyle?.social_media_accounts?.map(
|
||||
(item: any) => ({
|
||||
...item,
|
||||
icon: getImagePath(item.icon, basePath),
|
||||
}),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WelcomeFooter = ({
|
||||
showBrand = true,
|
||||
}: {
|
||||
showBrand?: boolean;
|
||||
}) => {
|
||||
const { mobile = false, catalogWidth, kbDetail } = useStore();
|
||||
const basePath = useBasePath();
|
||||
const footerSetting = kbDetail?.settings?.footer_settings;
|
||||
const customStyle = kbDetail?.settings?.web_app_custom_style;
|
||||
return (
|
||||
<WelcomeFooterComponent
|
||||
mobile={mobile}
|
||||
catalogWidth={catalogWidth}
|
||||
showBrand={showBrand}
|
||||
isDocPage={false}
|
||||
logo='https://release.baizhi.cloud/panda-wiki/icon.png'
|
||||
docWidth='full'
|
||||
footerSetting={
|
||||
footerSetting
|
||||
? {
|
||||
...footerSetting,
|
||||
brand_logo: getImagePath(footerSetting?.brand_logo, basePath),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
customStyle={{
|
||||
...customStyle,
|
||||
social_media_accounts: customStyle?.social_media_accounts?.map(
|
||||
(item: any) => ({
|
||||
...item,
|
||||
icon: getImagePath(item.icon, basePath),
|
||||
}),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
181
web/app/src/components/header/index.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import Logo from '@/assets/images/logo.png';
|
||||
import { useBasePath } from '@/hooks';
|
||||
import { useStore } from '@/provider';
|
||||
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
|
||||
import { getImagePath } from '@/utils/getImagePath';
|
||||
import { Modal } from '@ctzhian/ui';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import { alpha, Box, IconButton, Stack, Tooltip } from '@mui/material';
|
||||
import { IconDengchu } from '@panda-wiki/icons';
|
||||
import {
|
||||
Header as CustomHeader,
|
||||
WelcomeHeader as WelcomeHeaderComponent,
|
||||
} from '@panda-wiki/ui';
|
||||
import { useMemo, useState } from 'react';
|
||||
import QaModal from '../QaModal';
|
||||
import ThemeSwitch from './themeSwitch';
|
||||
interface HeaderProps {
|
||||
isDocPage?: boolean;
|
||||
isWelcomePage?: boolean;
|
||||
}
|
||||
|
||||
const LogoutButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleLogout = () => {
|
||||
return postShareProV1AuthLogout().then(() => {
|
||||
// 使用当前页面的协议(http 或 https)
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
window.location.href = `${protocol}//${host}/auth/login`;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={
|
||||
<Stack direction='row' alignItems='center' gap={1}>
|
||||
<ErrorIcon sx={{ fontSize: 24, color: 'warning.main' }} />
|
||||
<Box sx={{ mt: '2px' }}>提示</Box>
|
||||
</Stack>
|
||||
}
|
||||
open={open}
|
||||
okText='确定'
|
||||
cancelText='取消'
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={handleLogout}
|
||||
closable={false}
|
||||
>
|
||||
<Box sx={{ pl: 4 }}>确定要退出登录吗?</Box>
|
||||
</Modal>
|
||||
<Tooltip title='退出登录' arrow>
|
||||
<IconButton size='small' onClick={() => setOpen(true)}>
|
||||
<IconDengchu
|
||||
sx={theme => ({
|
||||
cursor: 'pointer',
|
||||
color: alpha(theme.palette.text.primary, 0.65),
|
||||
fontSize: 24,
|
||||
'&:hover': { color: theme.palette.primary.main },
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
|
||||
const {
|
||||
mobile = false,
|
||||
kbDetail,
|
||||
catalogWidth,
|
||||
setQaModalOpen,
|
||||
authInfo,
|
||||
} = useStore();
|
||||
const basePath = useBasePath();
|
||||
const docWidth = useMemo(() => {
|
||||
if (isWelcomePage) return 'full';
|
||||
return kbDetail?.settings?.theme_and_style?.doc_width || 'full';
|
||||
}, [kbDetail, isWelcomePage]);
|
||||
|
||||
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
|
||||
if (value?.trim()) {
|
||||
if (type === 'chat') {
|
||||
sessionStorage.setItem('chat_search_query', value.trim());
|
||||
setQaModalOpen?.(true);
|
||||
} else {
|
||||
sessionStorage.setItem('chat_search_query', value.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomHeader
|
||||
isDocPage={isDocPage}
|
||||
mobile={mobile}
|
||||
docWidth={docWidth}
|
||||
catalogWidth={catalogWidth}
|
||||
logo={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
|
||||
title={kbDetail?.settings?.title}
|
||||
placeholder={
|
||||
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder
|
||||
}
|
||||
showSearch
|
||||
homePath={basePath || '/'}
|
||||
btns={
|
||||
kbDetail?.settings?.btns?.map((item: any) => ({
|
||||
...item,
|
||||
url: getImagePath(item.url, basePath),
|
||||
icon: getImagePath(item.icon, basePath),
|
||||
})) || []
|
||||
}
|
||||
onSearch={handleSearch}
|
||||
onQaClick={() => setQaModalOpen?.(true)}
|
||||
>
|
||||
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
|
||||
<ThemeSwitch />
|
||||
{!!authInfo && <LogoutButton />}
|
||||
</Stack>
|
||||
<QaModal />
|
||||
</CustomHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const WelcomeHeader = ({
|
||||
showSearch = true,
|
||||
}: {
|
||||
showSearch?: boolean;
|
||||
}) => {
|
||||
const basePath = useBasePath();
|
||||
const {
|
||||
mobile = false,
|
||||
kbDetail,
|
||||
catalogWidth,
|
||||
setQaModalOpen,
|
||||
authInfo,
|
||||
} = useStore();
|
||||
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
|
||||
if (value?.trim()) {
|
||||
if (type === 'chat') {
|
||||
sessionStorage.setItem('chat_search_query', value.trim());
|
||||
setQaModalOpen?.(true);
|
||||
} else {
|
||||
sessionStorage.setItem('chat_search_query', value.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<WelcomeHeaderComponent
|
||||
isDocPage={false}
|
||||
mobile={mobile}
|
||||
docWidth='full'
|
||||
catalogWidth={catalogWidth}
|
||||
logo={getImagePath(kbDetail?.settings?.icon || Logo.src, basePath)}
|
||||
title={kbDetail?.settings?.title}
|
||||
placeholder={
|
||||
kbDetail?.settings?.web_app_custom_style?.header_search_placeholder
|
||||
}
|
||||
showSearch={showSearch}
|
||||
homePath={basePath || '/'}
|
||||
btns={
|
||||
kbDetail?.settings?.btns?.map((item: any) => ({
|
||||
...item,
|
||||
url: getImagePath(item.url, basePath),
|
||||
icon: getImagePath(item.icon, basePath),
|
||||
})) || []
|
||||
}
|
||||
onSearch={handleSearch}
|
||||
onQaClick={() => setQaModalOpen?.(true)}
|
||||
>
|
||||
{!!authInfo && (
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<LogoutButton />
|
||||
</Box>
|
||||
)}
|
||||
<QaModal />
|
||||
</WelcomeHeaderComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||