commit projects
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Base webpack config used across other specific configs
|
||||
*/
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import dotenv from 'dotenv'; // 导入dotenv-webpack插件
|
||||
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import { dependencies as externals } from '../../release/app/package.json';
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
externals: [...Object.keys(externals || {})],
|
||||
|
||||
stats: 'errors-only',
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.[jt]sx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
// Remove this line to enable type checking in webpack builds
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'esnext',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
output: {
|
||||
path: webpackPaths.srcPath,
|
||||
// https://github.com/webpack/webpack/issues/1114
|
||||
library: {
|
||||
type: 'commonjs2',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the array of extensions that should be used to resolve modules.
|
||||
*/
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
|
||||
modules: [webpackPaths.srcPath, 'node_modules'],
|
||||
// There is no need to add aliases here, the paths in tsconfig get mirrored
|
||||
plugins: [new TsconfigPathsPlugins()],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
...dotenv.config({
|
||||
path: path.join(webpackPaths.rootPath, '.env'),
|
||||
}).parsed,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default configuration;
|
||||
@@ -0,0 +1,3 @@
|
||||
/* eslint import/no-unresolved: off, import/no-self-import: off */
|
||||
|
||||
module.exports = require('./webpack.config.renderer.dev').default;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Webpack config for production electron main process
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import { merge } from 'webpack-merge';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||
|
||||
checkNodeEnv('production');
|
||||
deleteSourceMaps();
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'source-map',
|
||||
|
||||
mode: 'production',
|
||||
|
||||
target: 'electron-main',
|
||||
|
||||
entry: {
|
||||
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
|
||||
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distMainPath,
|
||||
filename: '[name].js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
analyzerPort: 8888,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
START_MINIMIZED: false,
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
'process.type': '"browser"',
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
* Disables webpack processing of __dirname and __filename.
|
||||
* If you run the bundle in node.js it falls back to these values of node.js.
|
||||
* https://github.com/webpack/webpack/issues/2010
|
||||
*/
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -0,0 +1,71 @@
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import { merge } from 'webpack-merge';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||
// at the dev webpack config is not accidentally run in a production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: 'electron-preload',
|
||||
|
||||
entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
|
||||
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
filename: 'preload.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
* Disables webpack processing of __dirname and __filename.
|
||||
* If you run the bundle in node.js it falls back to these values of node.js.
|
||||
* https://github.com/webpack/webpack/issues/2010
|
||||
*/
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
watch: true,
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Builds the DLL for development electron renderer process
|
||||
*/
|
||||
|
||||
import webpack from 'webpack';
|
||||
import path from 'path';
|
||||
import { merge } from 'webpack-merge';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import { dependencies } from '../../package.json';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
checkNodeEnv('development');
|
||||
|
||||
const dist = webpackPaths.dllPath;
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
context: webpackPaths.rootPath,
|
||||
|
||||
devtool: 'eval',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: 'electron-renderer',
|
||||
|
||||
externals: ['fsevents', 'crypto-browserify'],
|
||||
|
||||
/**
|
||||
* Use `module` from `webpack.config.renderer.dev.js`
|
||||
*/
|
||||
module: require('./webpack.config.renderer.dev').default.module,
|
||||
|
||||
entry: {
|
||||
renderer: Object.keys(dependencies || {}),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: dist,
|
||||
filename: '[name].dev.dll.js',
|
||||
library: {
|
||||
name: 'renderer',
|
||||
type: 'var',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new webpack.DllPlugin({
|
||||
path: path.join(dist, '[name].json'),
|
||||
name: '[name]',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
options: {
|
||||
context: webpackPaths.srcPath,
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'webpack-dev-server';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import webpack from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import chalk from 'chalk';
|
||||
import { merge } from 'webpack-merge';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||
// at the dev webpack config is not accidentally run in a production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 1212;
|
||||
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
|
||||
const skipDLLs =
|
||||
module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
|
||||
module.parent?.filename.includes('webpack.config.eslint');
|
||||
|
||||
/**
|
||||
* Warn if the DLL is not built
|
||||
*/
|
||||
if (
|
||||
!skipDLLs &&
|
||||
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
|
||||
) {
|
||||
console.log(
|
||||
chalk.black.bgYellow.bold(
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
|
||||
),
|
||||
);
|
||||
execSync('npm run postinstall');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [
|
||||
`webpack-dev-server/client?http://localhost:${port}/dist`,
|
||||
'webpack/hot/only-dev-server',
|
||||
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
|
||||
],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: '/',
|
||||
filename: 'renderer.dev.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(c|a)ss$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// SVG
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
prettier: false,
|
||||
svgo: false,
|
||||
svgoConfig: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
...(skipDLLs
|
||||
? []
|
||||
: [
|
||||
new webpack.DllReferencePlugin({
|
||||
context: webpackPaths.dllPath,
|
||||
manifest: require(manifest),
|
||||
sourceType: 'var',
|
||||
}),
|
||||
]),
|
||||
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
|
||||
new ReactRefreshWebpackPlugin(),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
}),
|
||||
],
|
||||
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
devServer: {
|
||||
port,
|
||||
compress: true,
|
||||
hot: true,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
static: {
|
||||
publicPath: '/',
|
||||
},
|
||||
historyApiFallback: {
|
||||
verbose: true,
|
||||
},
|
||||
setupMiddlewares(middlewares) {
|
||||
console.log('Starting preload.js builder...');
|
||||
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => process.exit(code!))
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
|
||||
console.log('Starting Main Process...');
|
||||
let args = ['run', 'start:main'];
|
||||
if (process.env.MAIN_ARGS) {
|
||||
args = args.concat(
|
||||
['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(),
|
||||
);
|
||||
}
|
||||
spawn('npm', args, {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => {
|
||||
preloadProcess.kill();
|
||||
process.exit(code!);
|
||||
})
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
return middlewares;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Build config for electron renderer process
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||
import { merge } from 'webpack-merge';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||
|
||||
checkNodeEnv('production');
|
||||
deleteSourceMaps();
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'source-map',
|
||||
|
||||
mode: 'production',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: './',
|
||||
filename: 'renderer.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// SVG
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
prettier: false,
|
||||
svgo: false,
|
||||
svgoConfig: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
}),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'style.css',
|
||||
}),
|
||||
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
analyzerPort: 8889,
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: false,
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
'process.type': '"renderer"',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -0,0 +1,38 @@
|
||||
const path = require('path');
|
||||
|
||||
const rootPath = path.join(__dirname, '../..');
|
||||
|
||||
const dllPath = path.join(__dirname, '../dll');
|
||||
|
||||
const srcPath = path.join(rootPath, 'src');
|
||||
const srcMainPath = path.join(srcPath, 'main');
|
||||
const srcRendererPath = path.join(srcPath, 'renderer');
|
||||
|
||||
const releasePath = path.join(rootPath, 'release');
|
||||
const appPath = path.join(releasePath, 'app');
|
||||
const appPackagePath = path.join(appPath, 'package.json');
|
||||
const appNodeModulesPath = path.join(appPath, 'node_modules');
|
||||
const srcNodeModulesPath = path.join(srcPath, 'node_modules');
|
||||
|
||||
const distPath = path.join(appPath, 'dist');
|
||||
const distMainPath = path.join(distPath, 'main');
|
||||
const distRendererPath = path.join(distPath, 'renderer');
|
||||
|
||||
const buildPath = path.join(releasePath, 'build');
|
||||
|
||||
export default {
|
||||
rootPath,
|
||||
dllPath,
|
||||
srcPath,
|
||||
srcMainPath,
|
||||
srcRendererPath,
|
||||
releasePath,
|
||||
appPath,
|
||||
appPackagePath,
|
||||
appNodeModulesPath,
|
||||
srcNodeModulesPath,
|
||||
distPath,
|
||||
distMainPath,
|
||||
distRendererPath,
|
||||
buildPath,
|
||||
};
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1 @@
|
||||
export default 'test-file-stub';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import/no-extraneous-dependencies": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Check if the renderer and main bundles are built
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
||||
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
||||
|
||||
if (!fs.existsSync(mainPath)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
'The main process is not built yet. Build it by running "npm run build:main"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rendererPath)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'child_process';
|
||||
import { dependencies } from '../../package.json';
|
||||
|
||||
if (dependencies) {
|
||||
const dependenciesKeys = Object.keys(dependencies);
|
||||
const nativeDeps = fs
|
||||
.readdirSync('node_modules')
|
||||
.filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
|
||||
if (nativeDeps.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
try {
|
||||
// Find the reason for why the dependency is installed. If it is installed
|
||||
// because of a devDependency then that is okay. Warn when it is installed
|
||||
// because of a dependency
|
||||
const { dependencies: dependenciesObject } = JSON.parse(
|
||||
execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(),
|
||||
);
|
||||
const rootDependencies = Object.keys(dependenciesObject);
|
||||
const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
|
||||
dependenciesKeys.includes(rootDependency),
|
||||
);
|
||||
if (filteredRootDependencies.length > 0) {
|
||||
const plural = filteredRootDependencies.length > 1;
|
||||
console.log(`
|
||||
${chalk.whiteBright.bgYellow.bold(
|
||||
'Webpack does not work with native dependencies.',
|
||||
)}
|
||||
${chalk.bold(filteredRootDependencies.join(', '))} ${
|
||||
plural ? 'are native dependencies' : 'is a native dependency'
|
||||
} and should be installed inside of the "./release/app" folder.
|
||||
First, uninstall the packages from "./package.json":
|
||||
${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
|
||||
${chalk.bold(
|
||||
'Then, instead of installing the package to the root "./package.json":',
|
||||
)}
|
||||
${chalk.whiteBright.bgRed.bold('npm install your-package')}
|
||||
${chalk.bold('Install the package to "./release/app/package.json"')}
|
||||
${chalk.whiteBright.bgGreen.bold(
|
||||
'cd ./release/app && npm install your-package',
|
||||
)}
|
||||
Read more about native dependencies at:
|
||||
${chalk.bold(
|
||||
'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure',
|
||||
)}
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Native dependencies could not be checked');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export default function checkNodeEnv(expectedEnv) {
|
||||
if (!expectedEnv) {
|
||||
throw new Error('"expectedEnv" not set');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== expectedEnv) {
|
||||
console.log(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`,
|
||||
),
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import chalk from 'chalk';
|
||||
import detectPort from 'detect-port';
|
||||
|
||||
const port = process.env.PORT || '1212';
|
||||
|
||||
detectPort(port, (_err, availablePort) => {
|
||||
if (port !== String(availablePort)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { rimrafSync } from 'rimraf';
|
||||
import fs from 'fs';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
const foldersToRemove = [
|
||||
webpackPaths.distPath,
|
||||
webpackPaths.buildPath,
|
||||
webpackPaths.dllPath,
|
||||
];
|
||||
|
||||
foldersToRemove.forEach((folder) => {
|
||||
if (fs.existsSync(folder)) rimrafSync(folder);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { rimrafSync } from 'rimraf';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
export default function deleteSourceMaps() {
|
||||
if (fs.existsSync(webpackPaths.distMainPath))
|
||||
rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), {
|
||||
glob: true,
|
||||
});
|
||||
if (fs.existsSync(webpackPaths.distRendererPath))
|
||||
rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), {
|
||||
glob: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import { dependencies } from '../../release/app/package.json';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
if (
|
||||
Object.keys(dependencies || {}).length > 0 &&
|
||||
fs.existsSync(webpackPaths.appNodeModulesPath)
|
||||
) {
|
||||
const electronRebuildCmd =
|
||||
'../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? electronRebuildCmd.replace(/\//g, '\\')
|
||||
: electronRebuildCmd;
|
||||
execSync(cmd, {
|
||||
cwd: webpackPaths.appPath,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
const { srcNodeModulesPath } = webpackPaths;
|
||||
const { appNodeModulesPath } = webpackPaths;
|
||||
|
||||
if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
|
||||
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const { notarize } = require('@electron/notarize');
|
||||
const { build } = require('../../package.json');
|
||||
|
||||
exports.default = async function notarizeMacos(context) {
|
||||
const { electronPlatformName, appOutDir } = context;
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CI !== 'true') {
|
||||
console.warn('Skipping notarizing step. Packaging is not running in CI');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!('APPLE_ID' in process.env && 'APPLE_APP_SPECIFIC_PASSWORD' in process.env)
|
||||
) {
|
||||
console.warn(
|
||||
'Skipping notarizing step. APPLE_ID and APPLE_APP_SPECIFIC_PASSWORD env variables must be set',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
|
||||
await notarize({
|
||||
appBundleId: build.appId,
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
.eslintcache
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
release/app/dist
|
||||
release/build
|
||||
.erb/dll
|
||||
|
||||
.idea
|
||||
npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
|
||||
# eslint ignores hidden directories by default:
|
||||
# https://github.com/eslint/eslint/issues/8429
|
||||
!.erb
|
||||
@@ -0,0 +1,47 @@
|
||||
module.exports = {
|
||||
extends: 'erb',
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
// A temporary hack related to IDE not resolving correct package.json
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-filename-extension': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-import-module-exports': 'off',
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'react/function-component-definition': 'off',
|
||||
'react/jsx-curly-brace-presence': 'off',
|
||||
'react/require-default-props': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react/destructuring-assignment': 'off',
|
||||
'import/no-named-as-default': 'off',
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'promise/no-promise-in-callback': 'off',
|
||||
'react/no-array-index-key': 'off',
|
||||
'class-methods-use-this': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
|
||||
node: {},
|
||||
webpack: {
|
||||
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
* text eol=lf
|
||||
*.exe binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
.eslintcache
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
release/app/dist
|
||||
release/build
|
||||
.erb/dll
|
||||
|
||||
.idea
|
||||
npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
assets/backend/*
|
||||
src/renderer/services/analytics/index.ts
|
||||
|
||||
.env
|
||||
.vscode
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
## 本地调试说明
|
||||
首先下载 https://wwp.lanzouo.com/ipTev1qulx1i 里面的文件并解压到你的项目目录 assets/backend 目录下
|
||||
|
||||
确定本地 Nodejs 环境没有问题后
|
||||
|
||||
然后运行以下命令,安装依赖
|
||||
|
||||
```bash
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
```
|
||||
|
||||
就可以运行以下命令,启动项目
|
||||
|
||||
下面是我的 vscode 配置文件,可以参考一下
|
||||
|
||||
```JSON
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Electron: Main",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"protocol": "inspector",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start"],
|
||||
"env": {
|
||||
"MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Electron: Renderer",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9223,
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"timeout": 15000
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Electron: All",
|
||||
"configurations": ["Electron: Main", "Electron: Renderer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# 1.0.0
|
||||
- [x] 多平台支持:当前支持哔哩哔哩、抖音企业号、抖音、抖店、微博聊天、小红书专业号运营、小红书、知乎等平台,未来将不断扩展支持更多社交媒体平台。
|
||||
- [x] 预设回复内容:允许用户设置自定义回复,以应对常见问题,提高回复效率。
|
||||
- [x] 接入ChatGPT接口,根据客户的咨询内容智能生成回复,适用于处理复杂或者个性化的客户咨询。
|
||||
- [x] 发送图片和二进制文件:支持发送图片等二进制文件,满足多样化的客户服务需求。
|
||||
- [x] 知识库: 通过上传知识库文件自定义专属机器人,可作为数字分身、智能客服、私域助手使用,基于 [懒人百宝箱](https://chat.lazaytools.top/) 实现
|
||||
- [x] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
|
||||
@@ -8,9 +8,12 @@
|
||||
- [x] 接入ChatGPT接口,根据客户的咨询内容智能生成回复,适用于处理复杂或者个性化的客户咨询。
|
||||
- [x] 发送图片和二进制文件:支持发送图片等二进制文件,满足多样化的客户服务需求。
|
||||
- [x] 知识库: 通过上传知识库文件自定义专属机器人,可作为数字分身、智能客服、私域助手使用,基于 [懒人百宝箱](https://chat.lazaytools.top/) 实现
|
||||
- [X] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
|
||||
- [x] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
|
||||
|
||||
|
||||
## 演示视频
|
||||
[观看视频](https://www.bilibili.com/video/BV1qz421Q73S)
|
||||
|
||||
## 开源社区
|
||||
如果有问题需要反馈,或者对项目有什么特性希望支持的,可以添加小助手微信加入开源项目交流群:
|
||||
|
||||
@@ -27,7 +30,8 @@
|
||||
|
||||
## 下载地址
|
||||
|
||||
[点击下载]()
|
||||
<a href="https://wwp.lanzouo.com/iCntL1qukopc" style="display: inline-block; background-color: #008CBA; color: white; padding: 10px 20px; text-align: center; text-decoration: none; font-weight: bold; border-radius: 5px; margin: 4px 2px; cursor: pointer;">点击下载</a>
|
||||
|
||||
|
||||
## 使用说明
|
||||
第一次启动时可能会有点慢,因为它需要先下载驱动文件和初始化回复数据库,所以请耐心等待。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
type Styles = Record<string, string>;
|
||||
|
||||
declare module '*.svg' {
|
||||
import React = require('react');
|
||||
|
||||
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.sass' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,30 @@
|
||||
appId: org.lrhh123.cocs
|
||||
productName: 懒人客服
|
||||
asar: true
|
||||
asarUnpack: "**\\*.{node,dll}"
|
||||
compression: maximum
|
||||
files:
|
||||
- dist
|
||||
- node_modules
|
||||
- package.json
|
||||
afterSign: ".erb/scripts/notarize.js"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: true
|
||||
allowElevation: true
|
||||
allowToChangeInstallationDirectory: true
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: 懒人客服
|
||||
uninstallDisplayName: ChatGPT-On-CS
|
||||
deleteAppDataOnUninstall: true
|
||||
artifactName: "${productName} ${version}.${ext}"
|
||||
directories:
|
||||
app: release/app
|
||||
buildResources: assets
|
||||
output: release/build
|
||||
extraResources:
|
||||
- "./assets/**"
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"name": "chatgpt-on-cs",
|
||||
"description": "多平台智能客服,允许使用 ChatGPT 作为客服机器人",
|
||||
"version": "0.9.0",
|
||||
"keywords": [
|
||||
"electron",
|
||||
"boilerplate",
|
||||
"react",
|
||||
"typescript",
|
||||
"ts",
|
||||
"sass",
|
||||
"webpack",
|
||||
"hot",
|
||||
"reload"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/lrhh123/ChatGPT-On-CS"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/lrhh123/ChatGPT-On-CS.git"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "lrhh123",
|
||||
"email": "lrhh123@users.noreply.github.com",
|
||||
"url": "https://github.com/lrhh123"
|
||||
}
|
||||
],
|
||||
"main": "./src/main/main.ts",
|
||||
"scripts": {
|
||||
"build": "pnpx concurrently \"pnpm run build:main\" \"pnpm run build:renderer\"",
|
||||
"build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && pnpm run build:dll",
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && pnpm run build && electron-builder build --publish never && pnpm run build:dll",
|
||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && pnpm run start:renderer",
|
||||
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"browserslist": [],
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
".prettierrc",
|
||||
".eslintrc"
|
||||
],
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleDirectories": [
|
||||
"node_modules",
|
||||
"release/app/node_modules",
|
||||
"src"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"json"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
|
||||
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"setupFiles": [
|
||||
"./.erb/scripts/check-build-exists.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost/"
|
||||
},
|
||||
"testPathIgnorePatterns": [
|
||||
"release/app/dist",
|
||||
".erb/dll"
|
||||
],
|
||||
"transform": {
|
||||
"\\.(ts|tsx|js|jsx)$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@agconnect/api": "^1.3.2",
|
||||
"@agconnect/instance": "^1.3.2",
|
||||
"@chakra-ui/anatomy": "^2.2.2",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hw-hmscore/analytics-web": "6.9.9-301",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"axios": "^1.6.7",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^6.1.4",
|
||||
"framer-motion": "^11.0.3",
|
||||
"immer": "^10.0.3",
|
||||
"net": "^1.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.1",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-spinners": "^0.13.8",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^2.1.0",
|
||||
"@electron/rebuild": "^3.3.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
|
||||
"@testing-library/jest-dom": "^6.1.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-test-renderer": "^18.0.1",
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webpack-bundle-analyzer": "^4.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.7.0",
|
||||
"browserslist-config-erb": "^0.0.3",
|
||||
"chalk": "^4.1.2",
|
||||
"concurrently": "^8.2.1",
|
||||
"core-js": "^3.32.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^6.8.1",
|
||||
"css-minimizer-webpack-plugin": "^5.0.1",
|
||||
"detect-port": "^1.5.1",
|
||||
"dotenv": "^16.4.4",
|
||||
"electron": "^26.2.1",
|
||||
"electron-builder": "^24.6.4",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electronmon": "^2.0.2",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-erb": "^4.1.0-0",
|
||||
"eslint-import-resolver-typescript": "^3.6.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.7",
|
||||
"eslint-plugin-compat": "^4.2.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jest": "^27.4.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.7.6",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-eslint": "^16.3.0",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"sass": "^1.67.0",
|
||||
"sass-loader": "^13.3.2",
|
||||
"style-loader": "^3.3.3",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"typescript": "^5.2.2",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.1",
|
||||
"webpack-merge": "^5.9.0"
|
||||
},
|
||||
"devEngines": {
|
||||
"node": ">=14.x",
|
||||
"npm": ">=7.x"
|
||||
},
|
||||
"electronmon": {
|
||||
"patterns": [
|
||||
"!**/**",
|
||||
"src/main/**"
|
||||
],
|
||||
"logLevel": "quiet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import App from '../renderer/App';
|
||||
|
||||
describe('App', () => {
|
||||
it('should render', () => {
|
||||
expect(render(<App />)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ipcMain,
|
||||
dialog,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
app,
|
||||
Notification,
|
||||
} from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { setCron } from './system/cron';
|
||||
import type BackendServiceManager from './system/backend';
|
||||
import { getBrowserVersionFromOS } from './system/chrome';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
const setupIpcHandlers = (
|
||||
mainWindow: BrowserWindow,
|
||||
bsm: BackendServiceManager,
|
||||
) => {
|
||||
ipcMain.on('get-env', async (event, key) => {
|
||||
event.returnValue = process.env[key];
|
||||
});
|
||||
|
||||
ipcMain.on('get-port', async (event) => {
|
||||
event.returnValue = bsm.getPort();
|
||||
});
|
||||
|
||||
ipcMain.on('ipc-example', async (event) => {
|
||||
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
|
||||
event.reply('ipc-example', msgTemplate('pong'));
|
||||
});
|
||||
|
||||
ipcMain.on('select-directory', async (event) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
event.reply('selected-directory', result.filePaths);
|
||||
});
|
||||
|
||||
ipcMain.on('select-file', async (event) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
});
|
||||
event.reply('selected-file', result.filePaths);
|
||||
});
|
||||
|
||||
ipcMain.on('open-directory', async (event, args) => {
|
||||
shell.openPath(args);
|
||||
});
|
||||
|
||||
ipcMain.on('electron-store-get', async (event, val) => {
|
||||
event.returnValue = store.get(val);
|
||||
});
|
||||
|
||||
ipcMain.on('electron-store-set', async (event, key, val) => {
|
||||
store.set(key, val);
|
||||
});
|
||||
|
||||
ipcMain.on('electron-store-remove', async (event, key) => {
|
||||
store.delete(key);
|
||||
});
|
||||
|
||||
ipcMain.on('get-version', async (event) => {
|
||||
event.returnValue = app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.on('open-url', async (event, url) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.on('get-browser-version', async (event) => {
|
||||
const version = await getBrowserVersionFromOS();
|
||||
event.returnValue = version;
|
||||
});
|
||||
|
||||
ipcMain.on('notification', async (event, title, message) => {
|
||||
const notification = {
|
||||
title,
|
||||
body: message,
|
||||
};
|
||||
new Notification(notification).show();
|
||||
});
|
||||
|
||||
// 每隔 5 秒执行一次
|
||||
setCron('*/5 * * * * *', () => {
|
||||
mainWindow.webContents.send('refresh-config');
|
||||
});
|
||||
|
||||
// 每隔 5 秒执行一次检查后端服务是否健康
|
||||
setCron('*/5 * * * * *', async () => {
|
||||
if (!bsm) {
|
||||
console.error('BackendServiceManager not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Checking health...');
|
||||
const isHealthy = await bsm.check_health();
|
||||
mainWindow.webContents.send('check-health', isHealthy);
|
||||
});
|
||||
};
|
||||
|
||||
export default setupIpcHandlers;
|
||||
@@ -0,0 +1,133 @@
|
||||
/* eslint global-require: off, no-console: off, promise/always-return: off */
|
||||
|
||||
/**
|
||||
* This module executes inside of electron's main process. You can start
|
||||
* electron renderer process from here and communicate with the other processes
|
||||
* through IPC.
|
||||
*
|
||||
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { app, BrowserWindow, shell } from 'electron';
|
||||
import MenuBuilder from './menu';
|
||||
import { resolveHtmlPath } from './util';
|
||||
import setupIpcHandlers from './ipcHandlers';
|
||||
import BackendServiceManager from './system/backend';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let backendServiceManager: BackendServiceManager | null = null;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const sourceMapSupport = require('source-map-support');
|
||||
sourceMapSupport.install();
|
||||
}
|
||||
|
||||
const isDebug =
|
||||
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
|
||||
|
||||
if (isDebug) {
|
||||
require('electron-debug')();
|
||||
}
|
||||
|
||||
const installExtensions = async () => {
|
||||
const installer = require('electron-devtools-installer');
|
||||
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
|
||||
const extensions = ['REACT_DEVELOPER_TOOLS'];
|
||||
|
||||
return installer
|
||||
.default(
|
||||
extensions.map((name) => installer[name]),
|
||||
forceDownload,
|
||||
)
|
||||
.catch(console.log);
|
||||
};
|
||||
|
||||
const createWindow = async () => {
|
||||
if (isDebug) {
|
||||
await installExtensions();
|
||||
}
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
|
||||
backendServiceManager = new BackendServiceManager(
|
||||
path.join(
|
||||
RESOURCES_PATH,
|
||||
process.env.BKEXE_PATH || './backend/__main__.exe',
|
||||
),
|
||||
);
|
||||
await backendServiceManager.start();
|
||||
|
||||
const getAssetPath = (...paths: string[]): string => {
|
||||
return path.join(RESOURCES_PATH, ...paths);
|
||||
};
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 528,
|
||||
height: 1024,
|
||||
resizable: false, // 防止用户调整窗口大小
|
||||
icon: getAssetPath('icon.png'),
|
||||
webPreferences: {
|
||||
preload: app.isPackaged
|
||||
? path.join(__dirname, 'preload.js')
|
||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||
},
|
||||
});
|
||||
|
||||
setupIpcHandlers(mainWindow, backendServiceManager);
|
||||
|
||||
mainWindow.loadURL(resolveHtmlPath('index.html'));
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
if (!mainWindow) {
|
||||
throw new Error('"mainWindow" is not defined');
|
||||
}
|
||||
if (process.env.START_MINIMIZED) {
|
||||
mainWindow.minimize();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
const menuBuilder = new MenuBuilder(mainWindow);
|
||||
menuBuilder.buildMenu();
|
||||
|
||||
// Open urls in the user's browser
|
||||
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
||||
shell.openExternal(edata.url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Add event listeners...
|
||||
*/
|
||||
|
||||
app.on('window-all-closed', async () => {
|
||||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
console.log('window-all-closed');
|
||||
await backendServiceManager?.stop();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (mainWindow === null) createWindow();
|
||||
});
|
||||
})
|
||||
.catch(console.log);
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
app,
|
||||
Menu,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
|
||||
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
|
||||
selector?: string;
|
||||
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
|
||||
}
|
||||
|
||||
export default class MenuBuilder {
|
||||
mainWindow: BrowserWindow;
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
}
|
||||
|
||||
buildMenu() {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
) {
|
||||
this.setupDevelopmentEnvironment();
|
||||
const template =
|
||||
process.platform === 'darwin'
|
||||
? this.buildDarwinTemplate()
|
||||
: this.buildDefaultTemplate();
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
} else {
|
||||
Menu.setApplicationMenu(null);
|
||||
this.mainWindow.setMenu(null);
|
||||
}
|
||||
}
|
||||
|
||||
setupDevelopmentEnvironment(): void {
|
||||
this.mainWindow.webContents.on('context-menu', (_, props) => {
|
||||
const { x, y } = props;
|
||||
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Inspect element',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.inspectElement(x, y);
|
||||
},
|
||||
},
|
||||
]).popup({ window: this.mainWindow });
|
||||
});
|
||||
}
|
||||
|
||||
buildDarwinTemplate(): MenuItemConstructorOptions[] {
|
||||
const subMenuAbout: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Electron',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About ElectronReact',
|
||||
selector: 'orderFrontStandardAboutPanel:',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Services', submenu: [] },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Hide ElectronReact',
|
||||
accelerator: 'Command+H',
|
||||
selector: 'hide:',
|
||||
},
|
||||
{
|
||||
label: 'Hide Others',
|
||||
accelerator: 'Command+Shift+H',
|
||||
selector: 'hideOtherApplications:',
|
||||
},
|
||||
{ label: 'Show All', selector: 'unhideAllApplications:' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuEdit: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' },
|
||||
{ label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', accelerator: 'Command+X', selector: 'cut:' },
|
||||
{ label: 'Copy', accelerator: 'Command+C', selector: 'copy:' },
|
||||
{ label: 'Paste', accelerator: 'Command+V', selector: 'paste:' },
|
||||
{
|
||||
label: 'Select All',
|
||||
accelerator: 'Command+A',
|
||||
selector: 'selectAll:',
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuViewDev: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'Command+R',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: 'Ctrl+Command+F',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Developer Tools',
|
||||
accelerator: 'Alt+Command+I',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.toggleDevTools();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuViewProd: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: 'Ctrl+Command+F',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuWindow: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'Command+M',
|
||||
selector: 'performMiniaturize:',
|
||||
},
|
||||
{ label: 'Close', accelerator: 'Command+W', selector: 'performClose:' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
|
||||
],
|
||||
};
|
||||
const subMenuHelp: MenuItemConstructorOptions = {
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Community Discussions',
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search Issues',
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/issues');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const subMenuView =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
? subMenuViewDev
|
||||
: subMenuViewProd;
|
||||
|
||||
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
|
||||
}
|
||||
|
||||
buildDefaultTemplate() {
|
||||
const templateDefault = [
|
||||
{
|
||||
label: '&File',
|
||||
submenu: [
|
||||
{
|
||||
label: '&Open',
|
||||
accelerator: 'Ctrl+O',
|
||||
},
|
||||
{
|
||||
label: '&Close',
|
||||
accelerator: 'Ctrl+W',
|
||||
click: () => {
|
||||
this.mainWindow.close();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '&View',
|
||||
submenu:
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
? [
|
||||
{
|
||||
label: '&Reload',
|
||||
accelerator: 'Ctrl+R',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle &Full Screen',
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen(),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle &Developer Tools',
|
||||
accelerator: 'Alt+Ctrl+I',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.toggleDevTools();
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Toggle &Full Screen',
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen(),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Community Discussions',
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search Issues',
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/issues');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return templateDefault;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Disable no-unused-vars, broken for spread args
|
||||
/* eslint no-unused-vars: off */
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
export type Channels =
|
||||
| 'ipc-example'
|
||||
| 'get-env'
|
||||
| 'get-port'
|
||||
| 'check-health'
|
||||
| 'electron-store-get'
|
||||
| 'electron-store-set'
|
||||
| 'electron-store-remove'
|
||||
| 'refresh-config'
|
||||
| 'open-directory'
|
||||
| 'select-file'
|
||||
| 'selected-file'
|
||||
| 'select-directory'
|
||||
| 'selected-directory'
|
||||
| 'open-url'
|
||||
| 'notification'
|
||||
| 'get-browser-version'
|
||||
| 'get-version';
|
||||
|
||||
const electronHandler = {
|
||||
ipcRenderer: {
|
||||
sendMessage(channel: Channels, ...args: unknown[]) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
},
|
||||
get(channel: Channels) {
|
||||
return ipcRenderer.sendSync(channel);
|
||||
},
|
||||
on(channel: Channels, func: (...args: unknown[]) => void) {
|
||||
const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
|
||||
func(...args);
|
||||
ipcRenderer.on(channel, subscription);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, subscription);
|
||||
};
|
||||
},
|
||||
// 这个once方法是特别的,因为它确保了事件处理函数只会被调用一次,然后自动移除。
|
||||
once(channel: Channels, func: (...args: unknown[]) => void) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => func(...args));
|
||||
},
|
||||
remove(channel: Channels) {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
},
|
||||
store: {
|
||||
get(key: string) {
|
||||
return ipcRenderer.sendSync('electron-store-get', key);
|
||||
},
|
||||
set(key: string, value: string) {
|
||||
ipcRenderer.send('electron-store-set', key, value);
|
||||
},
|
||||
remove(key: string) {
|
||||
ipcRenderer.send('electron-store-remove', key);
|
||||
},
|
||||
},
|
||||
getEnv: (key: string) => {
|
||||
const v = ipcRenderer.sendSync('get-env', key);
|
||||
return v;
|
||||
},
|
||||
getPort: () => {
|
||||
const v = ipcRenderer.sendSync('get-port');
|
||||
return v;
|
||||
},
|
||||
};
|
||||
|
||||
export type ElectronHandler = typeof electronHandler;
|
||||
|
||||
// 把功能暴露给渲染进程
|
||||
contextBridge.exposeInMainWorld('electron', electronHandler);
|
||||
@@ -0,0 +1,130 @@
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { createServer } from 'net';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
class BackendServiceManager {
|
||||
private executablePath: string;
|
||||
|
||||
private process: ReturnType<typeof spawn> | null;
|
||||
|
||||
private port: number;
|
||||
|
||||
private autoRestart: boolean; // 新增自动重启标志
|
||||
|
||||
constructor(executablePath: string, autoRestart: boolean = true) {
|
||||
this.executablePath = executablePath;
|
||||
this.process = null;
|
||||
this.port = 0;
|
||||
this.autoRestart = autoRestart; // 是否自动重启
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.port = 9999;
|
||||
return;
|
||||
}
|
||||
|
||||
const port = await this.getAvailablePort();
|
||||
this.port = port;
|
||||
|
||||
this.launchProcess(port);
|
||||
|
||||
// 监听退出事件并可能重启
|
||||
this.process?.on('close', (code) => {
|
||||
console.log(`child process exited with code ${code}`);
|
||||
if (this.autoRestart) {
|
||||
console.log('Attempting to restart...');
|
||||
this.start().catch((err) => console.error('Failed to restart:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private launchProcess(port: number) {
|
||||
// 获取系统的临时文件夹路径
|
||||
const tempDir = os.tmpdir();
|
||||
// 在临时文件夹中创建一个名为 chatgpt-on-cs 的目录用于存放日志
|
||||
const logDir = path.join(tempDir, 'chatgpt-on-cs');
|
||||
// 如果目录不存在,则创建它
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir);
|
||||
}
|
||||
// 定义日志文件的路径
|
||||
const logFilePath = path.join(logDir, 'process.log');
|
||||
// 创建一个写入流
|
||||
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
|
||||
|
||||
this.process = spawn(this.executablePath, ['--port', port.toString()]);
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
console.log(`stdout: ${data}`); // 可选:依然在控制台输出
|
||||
logStream.write(`stdout: ${data}`); // 写入文件
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
console.error(`stderr: ${data}`); // 可选:依然在控制台输出
|
||||
logStream.write(`stderr: ${data}`); // 写入文件
|
||||
});
|
||||
|
||||
// 监听进程关闭事件,关闭写入流
|
||||
this.process.on('close', () => {
|
||||
logStream.close();
|
||||
});
|
||||
}
|
||||
|
||||
async getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
server.close(() => {
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async check_health() {
|
||||
// 发送 HTTP 请求检查服务是否健康
|
||||
try {
|
||||
const {
|
||||
data: { data },
|
||||
} = await axios.get(`http://127.0.0.1:${this.port}/api/v1/base/health`);
|
||||
return data.init_db;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
// 修改停止方法以标记不自动重启
|
||||
return new Promise((resolve, reject) => {
|
||||
this.autoRestart = false; // 停止时禁用自动重启
|
||||
if (this.process) {
|
||||
console.log('Killing process...');
|
||||
const { pid } = this.process;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
exec(`taskkill /pid ${pid} /T /F`, (error, stdout, stderr) => {
|
||||
this.process = null;
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({});
|
||||
});
|
||||
} else {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPort() {
|
||||
return this.port;
|
||||
}
|
||||
}
|
||||
|
||||
export default BackendServiceManager;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function readVersionFromCmd(cmd: string, pattern: RegExp) {
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { shell: 'powershell.exe' });
|
||||
const match = stdout.match(pattern);
|
||||
return match ? match[0] : null;
|
||||
} catch (error) {
|
||||
// 如果命令执行失败,这里会捕获到异常,但我们选择忽略它,因为可能是因为浏览器不存在
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBrowserVersionFromOS(): Promise<string | null> {
|
||||
const commands = [
|
||||
`(Get-Item -Path "$env:PROGRAMFILES\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
|
||||
`(Get-Item -Path "'$env:PROGRAMFILES (x86)'\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
|
||||
`(Get-Item -Path "$env:LOCALAPPDATA\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
|
||||
`(Get-ItemProperty -Path Registry::"HKCU\\SOFTWARE\\Google\\Chrome\\BLBeacon").version`,
|
||||
`(Get-ItemProperty -Path Registry::"HKLM\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Google Chrome").version`,
|
||||
];
|
||||
const pattern = /\d+\.\d+\.\d+/;
|
||||
|
||||
const firstHitTemplate = `$tmp = {expression}; if ($tmp) {echo $tmp; Exit;};`;
|
||||
const script = `$ErrorActionPreference='silentlycontinue'; ${commands
|
||||
.map((e) => firstHitTemplate.replace('{expression}', e))
|
||||
.join(' ')}`;
|
||||
|
||||
const version = await readVersionFromCmd(`${script}`, pattern);
|
||||
return version; // 如果所有命令都失败了,返回 null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import nodeCron from 'node-cron';
|
||||
|
||||
export const setCron = (time: string, cb: () => void) => {
|
||||
// second minute hour day month week
|
||||
return nodeCron.schedule(time, cb);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/* eslint import/prefer-default-export: off */
|
||||
import { URL } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
export function resolveHtmlPath(htmlFileName: string) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const port = process.env.PORT || 1212;
|
||||
const url = new URL(`http://localhost:${port}`);
|
||||
url.pathname = htmlFileName;
|
||||
return url.href;
|
||||
}
|
||||
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
|
||||
* See https://github.com/webpack-contrib/sass-loader#imports
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: auto; /* 或者使用 'scroll' 来强制显示滚动条 */
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'zhFont';
|
||||
src: url('../../assets/fonts/庞门正道标题体.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.font-zh {
|
||||
font-family: 'zhFont';
|
||||
}
|
||||
|
||||
.table-tiny th,
|
||||
.table-tiny td {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.table-tiny th:first-child,
|
||||
.table-tiny td:first-child {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--chakra-colors-gray-300) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--chakra-colors-gray-300);
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { ChakraProvider, Box, Flex } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import Navbar from './components/layout/Navbar';
|
||||
import Footer from './components/layout/Footer';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import HomePage from './pages/Home';
|
||||
import SettingsPage from './pages/Settings';
|
||||
import AboutPage from './pages/About';
|
||||
import MsgList from './pages/MsgList';
|
||||
import FullScreenLoader from './pages/FullScreenLoader';
|
||||
import PlatformSettings from './pages/Platforms';
|
||||
import { SettingsProvider } from './pages/Settings/SettingsContext';
|
||||
import Updater from './components/Updater';
|
||||
import SystemCheck from './components/SystemCheck';
|
||||
import { WebSocketProvider } from './hooks/useWebSocketContext';
|
||||
import './App.css';
|
||||
import theme from './ui/styles/theme';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
cacheTime: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on('check-health', (health) => {
|
||||
const h = health as boolean;
|
||||
setIsLoaded(h);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.remove('check-health');
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChakraProvider theme={theme}>
|
||||
<WebSocketProvider>
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
{isLoaded ? (
|
||||
<Flex direction="column" minH="100vh">
|
||||
<Navbar />
|
||||
<Box flex="1" mt={{ base: '4rem', md: '5rem' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/msg" element={<MsgList />} />
|
||||
<Route path="/platforms" element={<PlatformSettings />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<SettingsPage />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
) : (
|
||||
<FullScreenLoader />
|
||||
)}
|
||||
<SystemCheck />
|
||||
<Updater />
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</WebSocketProvider>
|
||||
</ChakraProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Link,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('unhandledrejection', this.handlePromiseRejection);
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener(
|
||||
'unhandledrejection',
|
||||
this.handlePromiseRejection,
|
||||
);
|
||||
}
|
||||
|
||||
handlePromiseRejection = (event: PromiseRejectionEvent) => {
|
||||
this.setState({ hasError: true, error: event.reason });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hasError, error } = this.state;
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100vw"
|
||||
height="100vh"
|
||||
backgroundColor="gray.200"
|
||||
zIndex="modal"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
padding="40px"
|
||||
>
|
||||
<Alert
|
||||
status="error"
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
height="auto"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
backgroundColor="white"
|
||||
>
|
||||
<AlertIcon boxSize="50px" mr={0} />
|
||||
<AlertTitle mt={4} mb={1} fontSize="xl">
|
||||
很抱歉,应用遇到了一些问题。
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm" mb={4}>
|
||||
{error?.toString() || '未知错误,请尝试刷新页面或稍后再试。'}
|
||||
</AlertDescription>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="solid"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
刷新页面
|
||||
</Button>
|
||||
<AlertDescription maxWidth="sm" mt={4}>
|
||||
如果问题仍然存在,请通过以下方式联系我们获取帮助:
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href="mailto:author@example.com"
|
||||
isExternal
|
||||
mt={2}
|
||||
color="teal.500"
|
||||
>
|
||||
author@example.com
|
||||
</Link>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { DotLoader } from 'react-spinners';
|
||||
import './loader.css';
|
||||
|
||||
const FullScreenLoader = () => {
|
||||
return (
|
||||
<div className="full-screen-loader">
|
||||
<DotLoader color="#36d7b7" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FullScreenLoader);
|
||||
@@ -0,0 +1,11 @@
|
||||
.full-screen-loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
.waitingAnimation > :last-child::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
transform: translate(4px, 2px) scaleY(1.3);
|
||||
animation: blink 0.6s infinite;
|
||||
}
|
||||
|
||||
.animation {
|
||||
height: 20px;
|
||||
|
||||
&::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
transform: translate(4px, 2px) scaleY(1.3);
|
||||
animation: blink 0.6s infinite;
|
||||
}
|
||||
}
|
||||
@keyframes blink {
|
||||
from,
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown > *:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.markdown > *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.markdown a.absent {
|
||||
color: #cc0000;
|
||||
}
|
||||
.markdown a.anchor {
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
left: 0;
|
||||
margin-left: -30px;
|
||||
padding-left: 30px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
cursor: text;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
.markdown h1 .mini-icon-link,
|
||||
.markdown h2 .mini-icon-link,
|
||||
.markdown h3 .mini-icon-link,
|
||||
.markdown h4 .mini-icon-link,
|
||||
.markdown h5 .mini-icon-link,
|
||||
.markdown h6 .mini-icon-link {
|
||||
display: none;
|
||||
}
|
||||
.markdown h1:hover a.anchor,
|
||||
.markdown h2:hover a.anchor,
|
||||
.markdown h3:hover a.anchor,
|
||||
.markdown h4:hover a.anchor,
|
||||
.markdown h5:hover a.anchor,
|
||||
.markdown h6:hover a.anchor {
|
||||
line-height: 1;
|
||||
margin-left: -22px;
|
||||
padding-left: 0;
|
||||
text-decoration: none;
|
||||
top: 15%;
|
||||
}
|
||||
.markdown h1:hover a.anchor .mini-icon-link,
|
||||
.markdown h2:hover a.anchor .mini-icon-link,
|
||||
.markdown h3:hover a.anchor .mini-icon-link,
|
||||
.markdown h4:hover a.anchor .mini-icon-link,
|
||||
.markdown h5:hover a.anchor .mini-icon-link,
|
||||
.markdown h6:hover a.anchor .mini-icon-link {
|
||||
display: inline-block;
|
||||
}
|
||||
.markdown h1 tt,
|
||||
.markdown h1 code,
|
||||
.markdown h2 tt,
|
||||
.markdown h2 code,
|
||||
.markdown h3 tt,
|
||||
.markdown h3 code,
|
||||
.markdown h4 tt,
|
||||
.markdown h4 code,
|
||||
.markdown h5 tt,
|
||||
.markdown h5 code,
|
||||
.markdown h6 tt,
|
||||
.markdown h6 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.markdown h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
.markdown h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.markdown h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.markdown h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.markdown h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.markdown h6 {
|
||||
font-size: 12px;
|
||||
}
|
||||
.markdown p,
|
||||
.markdown blockquote,
|
||||
.markdown ul,
|
||||
.markdown ol,
|
||||
.markdown dl,
|
||||
.markdown table,
|
||||
.markdown pre {
|
||||
margin: 14px 0;
|
||||
}
|
||||
.markdown > h2:first-child,
|
||||
.markdown > h1:first-child,
|
||||
.markdown > h1:first-child + h2,
|
||||
.markdown > h3:first-child,
|
||||
.markdown > h4:first-child,
|
||||
.markdown > h5:first-child,
|
||||
.markdown > h6:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.markdown a:first-child h1,
|
||||
.markdown a:first-child h2,
|
||||
.markdown a:first-child h3,
|
||||
.markdown a:first-child h4,
|
||||
.markdown a:first-child h5,
|
||||
.markdown a:first-child h6 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.markdown h1 + p,
|
||||
.markdown h2 + p,
|
||||
.markdown h3 + p,
|
||||
.markdown h4 + p,
|
||||
.markdown h5 + p,
|
||||
.markdown h6 + p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown li p.first {
|
||||
display: inline-block;
|
||||
}
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
.markdown ul.no-list,
|
||||
.markdown ol.no-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown ul li > *:first-child,
|
||||
.markdown ol li > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
// padding-left: 14px;
|
||||
}
|
||||
.markdown dl {
|
||||
padding: 0;
|
||||
}
|
||||
.markdown dl dt {
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
margin: 15px 0 5px;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown dl dt:first-child {
|
||||
padding: 0;
|
||||
}
|
||||
.markdown dl dt > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown dl dt > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown dl dd {
|
||||
margin: 0 0 15px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.markdown dl dd > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown dl dd > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown blockquote {
|
||||
border-left: 4px solid #dddddd;
|
||||
color: #777777;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.markdown blockquote > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown blockquote > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown table {
|
||||
width: 100%;
|
||||
}
|
||||
.markdown table th {
|
||||
font-weight: bold;
|
||||
}
|
||||
.markdown table th,
|
||||
.markdown table td {
|
||||
padding: 6px 13px;
|
||||
}
|
||||
.markdown table tr {
|
||||
// background-color: #ffffff;
|
||||
}
|
||||
.markdown table tr:nth-child(2n) {
|
||||
// background-color: #f0f0f0;
|
||||
}
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.markdown span.frame {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown span.frame > span {
|
||||
// border: 1px solid #dddddd;
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 13px 0 0;
|
||||
overflow: hidden;
|
||||
padding: 7px;
|
||||
width: auto;
|
||||
}
|
||||
.markdown span.frame span img {
|
||||
display: block;
|
||||
float: left;
|
||||
}
|
||||
.markdown span.frame span span {
|
||||
clear: both;
|
||||
color: #333333;
|
||||
display: block;
|
||||
padding: 5px 0 0;
|
||||
}
|
||||
.markdown span.align-center {
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown span.align-center > span {
|
||||
display: block;
|
||||
margin: 13px auto 0;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
.markdown span.align-center span img {
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.markdown span.align-right {
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown span.align-right > span {
|
||||
display: block;
|
||||
margin: 13px 0 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
}
|
||||
.markdown span.align-right span img {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.markdown span.float-left {
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown span.float-left span {
|
||||
margin: 13px 0 0;
|
||||
}
|
||||
.markdown span.float-right {
|
||||
display: block;
|
||||
float: right;
|
||||
margin-left: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown span.float-right > span {
|
||||
display: block;
|
||||
margin: 13px auto 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
}
|
||||
.markdown code,
|
||||
.markdown tt {
|
||||
// border: 1px solid #dee0e2;
|
||||
// background-color: #f4f6f8;
|
||||
border-radius: 3px;
|
||||
margin: 0 2px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.markdown pre > code {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
border: medium none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown .highlight pre,
|
||||
.markdown pre {
|
||||
// border: 1px solid #cccccc;
|
||||
border-radius: 3px 3px 3px 3px;
|
||||
font-size: max(0.9em, 14px);
|
||||
line-height: 19px;
|
||||
overflow: auto;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.markdown pre code,
|
||||
.markdown pre tt {
|
||||
background-color: transparent;
|
||||
border: medium none;
|
||||
}
|
||||
.markdown hr {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.markdown {
|
||||
tab-size: 4;
|
||||
word-spacing: normal;
|
||||
width: 100%;
|
||||
|
||||
* {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: #292b33 !important;
|
||||
overflow-x: auto;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: #292b33 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0px;
|
||||
color: var(--chakra-colors-gray-700);
|
||||
|
||||
thead tr:first-child th {
|
||||
border-bottom-width: 1px;
|
||||
border-left-width: 1px;
|
||||
border-top-width: 1px;
|
||||
// border-color: #ccc;
|
||||
background-color: rgba(236, 236, 241, 0.2);
|
||||
overflow: hidden;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 0.375rem;
|
||||
}
|
||||
&:last-child {
|
||||
border-right-width: 1px;
|
||||
border-top-right-radius: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom-width: 1px;
|
||||
border-left-width: 1px;
|
||||
// border-color: #ccc;
|
||||
|
||||
&:last-of-type {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
overflow: hidden;
|
||||
td {
|
||||
&:first-child {
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown, { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
// 使用正确的类型来确保与预期的ReactMarkdown组件兼容
|
||||
const components: Components = {
|
||||
// @ts-ignore
|
||||
a: ({ node, ...props }) => ( // eslint-disable-line
|
||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={styles.markdown}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Markdown);
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalContentProps,
|
||||
Box,
|
||||
Image,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
export interface MyModalProps extends ModalContentProps {
|
||||
iconSrc?: string;
|
||||
title?: any;
|
||||
isCentered?: boolean;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const MyModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
iconSrc,
|
||||
title,
|
||||
children,
|
||||
isCentered,
|
||||
w = 'auto',
|
||||
maxW = ['90vw', '600px'],
|
||||
...props
|
||||
}: MyModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => onClose && onClose()}
|
||||
autoFocus={false}
|
||||
isCentered={isCentered}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
w={w}
|
||||
minW={['90vw', '400px']}
|
||||
maxW={maxW}
|
||||
position={'relative'}
|
||||
maxH={'85vh'}
|
||||
{...props}
|
||||
>
|
||||
{!title && onClose && <ModalCloseButton zIndex={1} />}
|
||||
{!!title && (
|
||||
<ModalHeader
|
||||
display={'flex'}
|
||||
alignItems={'center'}
|
||||
fontWeight={500}
|
||||
background={'myBackground.100'}
|
||||
color={'myText.500'}
|
||||
py={'10px'}
|
||||
>
|
||||
{iconSrc && (
|
||||
<>
|
||||
<Image
|
||||
mr={3}
|
||||
objectFit={'contain'}
|
||||
alt=""
|
||||
src={iconSrc}
|
||||
w={'20px'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{title}
|
||||
<Box flex={1} />
|
||||
{onClose && (
|
||||
<ModalCloseButton position={'relative'} top={0} right={0} />
|
||||
)}
|
||||
</ModalHeader>
|
||||
)}
|
||||
|
||||
<Box
|
||||
overflow={props.overflow || 'overlay'}
|
||||
h={'100%'}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
bg={'myBackground.100'}
|
||||
color={'myText.500'}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyModal;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Box, Textarea, TextareaProps, Text } from '@chakra-ui/react';
|
||||
|
||||
type Props = TextareaProps & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const Editor = React.memo(function Editor({
|
||||
textareaRef,
|
||||
maxLength,
|
||||
onChange,
|
||||
value,
|
||||
...props
|
||||
}: Props & {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
onOpenModal?: () => void;
|
||||
}) {
|
||||
// @ts-ignore
|
||||
const [currentLength, setCurrentLength] = useState(value?.length || 0);
|
||||
|
||||
// 处理文本变化的函数
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCurrentLength(e.target.value.length); // 更新当前文本长度
|
||||
if (onChange) {
|
||||
onChange(e); // 如果有外部传入的onChange事件处理器,也调用它
|
||||
}
|
||||
};
|
||||
|
||||
// 处理初始值的情况
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
// @ts-ignore
|
||||
setCurrentLength(value.length);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Box h={'100%'} w={'100%'} position={'relative'}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
maxW={'100%'}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange} // 使用自定义的handleChange来更新文本长度
|
||||
value={value} // 确保Textarea显示的是传入的value
|
||||
{...props}
|
||||
/>
|
||||
{maxLength && ( // 当设置了maxLength时,展示当前字符数和最大允许字符数
|
||||
<Text fontSize="sm" position="absolute" right="4" bottom="4">
|
||||
{`${currentLength}/${maxLength}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const MyTextarea = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||
function MyTextarea(props, ref) {
|
||||
const TextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 将外部传入的ref绑定到Textarea上,确保外部能通过ref控制Textarea
|
||||
useEffect(() => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(TextareaRef.current);
|
||||
} else if (ref) {
|
||||
ref.current = TextareaRef.current;
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor textareaRef={TextareaRef} {...props} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default React.memo(MyTextarea);
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipProps } from '@chakra-ui/react';
|
||||
|
||||
interface Props extends TooltipProps {
|
||||
forceShow?: boolean;
|
||||
}
|
||||
|
||||
const MyTooltip = ({
|
||||
children,
|
||||
shouldWrapChildren = true,
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="tooltip"
|
||||
bg={'myBackground.100'}
|
||||
arrowShadowColor={' rgba(0,0,0,0.05)'}
|
||||
hasArrow
|
||||
arrowSize={12}
|
||||
offset={[-15, 15]}
|
||||
color={'myText.500'}
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius={'8px'}
|
||||
whiteSpace={'pre-wrap'}
|
||||
boxShadow={'1px 1px 10px rgba(0,0,0,0.2)'}
|
||||
shouldWrapChildren={shouldWrapChildren}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyTooltip;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, Flex, Spinner, BoxProps } from '@chakra-ui/react';
|
||||
|
||||
interface PageContainerProps extends BoxProps {
|
||||
children?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
text?: string;
|
||||
insertProps?: BoxProps;
|
||||
}
|
||||
|
||||
const PageContainer: React.FC<PageContainerProps> = ({
|
||||
children,
|
||||
isLoading = false,
|
||||
text = '',
|
||||
insertProps = {},
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Box h={'100%'} p={[0, 5]} px={[0, 6]} position={'relative'} {...props}>
|
||||
<Box h={'100%'} overflow={'overlay'} {...insertProps}>
|
||||
{children}
|
||||
</Box>
|
||||
{isLoading && (
|
||||
<Flex
|
||||
position={'absolute'}
|
||||
zIndex={1000}
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
flexDirection={'column'}
|
||||
>
|
||||
<Spinner
|
||||
thickness="4px"
|
||||
speed="0.65s"
|
||||
color="myPrimary.200"
|
||||
size="xl"
|
||||
/>
|
||||
{text && (
|
||||
<Box mt={2} fontWeight={'bold'}>
|
||||
{text}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer;
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
VStack,
|
||||
Text,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useWebSocketContext } from '../../hooks/useWebSocketContext';
|
||||
import { useSystemStore } from '../../stores/useSystemStore';
|
||||
|
||||
const SystemCheck = () => {
|
||||
const [humanTaskMsg, setHumanTaskMsg] = useState<string>('');
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const cancelRef = React.useRef<any>();
|
||||
const { registerEventHandler, acknowledgeMessage } = useWebSocketContext();
|
||||
const { setDriverSettings, driverSettings } = useSystemStore();
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = registerEventHandler((message) => {
|
||||
if (message.message === 'chrome_download') {
|
||||
acknowledgeMessage('chrome_download', message.event_id);
|
||||
setIsModalOpen(true);
|
||||
} else if (message.message === 'human_task') {
|
||||
if (!message.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.sendMessage(
|
||||
'notification',
|
||||
'警告',
|
||||
'有需要人工处理的消息,请手动处理,注意处理完成后请取消暂停勾选。',
|
||||
);
|
||||
|
||||
setDriverSettings({
|
||||
...driverSettings,
|
||||
isPaused: true,
|
||||
});
|
||||
|
||||
const data = message.data as {
|
||||
message: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
acknowledgeMessage('human_task', message.event_id);
|
||||
if (data.type === 'strategy') {
|
||||
setHumanTaskMsg(data.message);
|
||||
onOpen();
|
||||
} else if (data.type === 'system') {
|
||||
setHumanTaskMsg(data.message);
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时注销事件处理器
|
||||
return () => unregister();
|
||||
}, [registerEventHandler, acknowledgeMessage]); // eslint-disable-line
|
||||
|
||||
const confirmDownload = () => {
|
||||
window.electron.ipcRenderer.sendMessage(
|
||||
'open-url',
|
||||
'https://www.google.cn/chrome/',
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const version = window.electron.ipcRenderer.get('get-browser-version');
|
||||
if (!version) {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>安装浏览器</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
您还未安装 Chrome 浏览器,请先安装 Chrome
|
||||
浏览器,请您安装后再重新打开本应用。
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={confirmDownload}>
|
||||
立即安装
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
手动处理消息
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
<VStack>
|
||||
<Text>软件已暂停</Text>
|
||||
<Text>{humanTaskMsg}</Text>
|
||||
<Text>
|
||||
平台有需要人工处理的消息,请手动处理,注意处理完成后请取消暂停勾选。
|
||||
</Text>
|
||||
</VStack>
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose}>
|
||||
确定
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemCheck;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { getVersionInfo } from '../../services/system/controller';
|
||||
import Markdown from '../Markdown';
|
||||
|
||||
const Updater = () => {
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{
|
||||
version: string;
|
||||
url: string;
|
||||
description: string;
|
||||
} | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const cv = window.electron.ipcRenderer.get('get-version');
|
||||
const info = await getVersionInfo(cv);
|
||||
if (info) {
|
||||
setUpdateInfo(info);
|
||||
setCurrentVersion(cv);
|
||||
setIsUpdateModalOpen(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 确认更新则跳转到下载链接
|
||||
const confirmUpdate = () => {
|
||||
if (updateInfo) {
|
||||
window.electron.ipcRenderer.sendMessage('open-url', updateInfo.url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isUpdateModalOpen}
|
||||
onClose={() => setIsUpdateModalOpen(false)}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>版本更新</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{updateInfo && (
|
||||
<>
|
||||
<Text>
|
||||
当前版本是 {currentVersion} 检查到有最新版本{' '}
|
||||
{updateInfo?.version} 请问您是否立即更新?
|
||||
</Text>
|
||||
|
||||
<Box mt="20px">
|
||||
<Markdown content={updateInfo?.description} />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={confirmUpdate}>
|
||||
立即更新
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setIsUpdateModalOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Updater);
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, Stack, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
const Footer = () => {
|
||||
const bg = useColorModeValue('gray.100', 'gray.900');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="footer"
|
||||
role="contentinfo"
|
||||
maxW="7xl"
|
||||
py="3"
|
||||
px={{ base: '4', md: '8' }}
|
||||
bg={bg}
|
||||
>
|
||||
<Stack>
|
||||
<Text fontSize="sm" alignSelf={{ base: 'center' }}>
|
||||
© {new Date().getFullYear()} lrhh123. All rights reserved.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom'; // 导入 Link 组件
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
HamburgerIcon,
|
||||
SettingsIcon,
|
||||
InfoOutlineIcon,
|
||||
ChatIcon,
|
||||
CalendarIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { FiAirplay } from 'react-icons/fi';
|
||||
|
||||
const Navbar = () => {
|
||||
const navigate = useNavigate();
|
||||
const handleNavigate = (path: string) => () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="nav"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
padding="1rem"
|
||||
position="fixed"
|
||||
width="100%"
|
||||
top="0"
|
||||
bg={'white'}
|
||||
zIndex="1000"
|
||||
height="60px"
|
||||
>
|
||||
<Text as="h1" fontSize="2em" letterSpacing="tighter" className="font-zh">
|
||||
懒人客服
|
||||
</Text>
|
||||
|
||||
<Box display={{ md: 'none' }}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<HamburgerIcon />}
|
||||
variant="outline"
|
||||
/>
|
||||
<MenuList mb={2}>
|
||||
{/* 直接在MenuItem上使用onClick进行导航 */}
|
||||
<MenuItem icon={<ChatIcon />} onClick={handleNavigate('/')}>
|
||||
首页
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FiAirplay />}
|
||||
onClick={handleNavigate('/platforms')}
|
||||
>
|
||||
平台
|
||||
</MenuItem>
|
||||
<MenuItem icon={<CalendarIcon />} onClick={handleNavigate('/msg')}>
|
||||
记录
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<SettingsIcon />}
|
||||
onClick={handleNavigate('/settings')}
|
||||
>
|
||||
设置
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<InfoOutlineIcon />}
|
||||
onClick={handleNavigate('/about')}
|
||||
>
|
||||
关于
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Navbar);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useToast as uToast, UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
export const useToast = (props?: UseToastOptions) => {
|
||||
const toast = uToast({
|
||||
position: 'top',
|
||||
duration: 2000,
|
||||
...(props && props),
|
||||
});
|
||||
|
||||
return {
|
||||
toast,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
// 定义WebSocket消息类型
|
||||
interface WebSocketMessage {
|
||||
message: string;
|
||||
data: any;
|
||||
event_id: string;
|
||||
}
|
||||
|
||||
// 定义上下文类型
|
||||
interface WebSocketContextType {
|
||||
acknowledgeMessage: (event: string, eventId: string) => void;
|
||||
registerEventHandler: (
|
||||
handler: (message: WebSocketMessage) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
|
||||
export const WebSocketProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { lastMessage, sendMessage } = useWebSocket(
|
||||
`ws://127.0.0.1:${window.electron.getPort()}/api/v1/event/ws`,
|
||||
{
|
||||
shouldReconnect: () => {
|
||||
return true; // 总是尝试重连
|
||||
},
|
||||
reconnectInterval: 3000,
|
||||
},
|
||||
);
|
||||
const [eventHandlers, setEventHandlers] = useState<
|
||||
((message: WebSocketMessage) => void)[]
|
||||
>([]);
|
||||
|
||||
const acknowledgeMessage = useCallback(
|
||||
(event: string, eventId: string) => {
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
type: 'ack',
|
||||
event_type: event,
|
||||
event_id: eventId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
// 注册事件处理器
|
||||
const registerEventHandler = useCallback(
|
||||
(handler: (message: WebSocketMessage) => void) => {
|
||||
setEventHandlers((prevHandlers) => [...prevHandlers, handler]);
|
||||
// 返回注销该处理器的函数
|
||||
return () => {
|
||||
setEventHandlers((prevHandlers) =>
|
||||
prevHandlers.filter((h) => h !== handler),
|
||||
);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage !== null) {
|
||||
const message: WebSocketMessage = JSON.parse(lastMessage.data);
|
||||
eventHandlers.forEach((handler) => handler(message));
|
||||
}
|
||||
}, [lastMessage, eventHandlers]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
acknowledgeMessage,
|
||||
registerEventHandler,
|
||||
}),
|
||||
[acknowledgeMessage, registerEventHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={value}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use WebSocket context
|
||||
export const useWebSocketContext = () =>
|
||||
useContext(WebSocketContext) as WebSocketContextType;
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
<title>懒人客服</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
|
||||
const container = document.getElementById('root') as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
|
||||
// calling IPC exposed from preload script
|
||||
window.electron.ipcRenderer.once('ipc-example', (arg) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(arg);
|
||||
});
|
||||
window.electron.ipcRenderer.sendMessage('ipc-example', ['ping']);
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import Markdown from '../../components/Markdown';
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Markdown
|
||||
content={`
|
||||
本项目是基于大模型的智能对话客服工具,支持哔哩哔哩、抖音企业号、抖音、抖店、微博聊天、小红书专业号运营、小红书、知乎等平台接入,可选择 GPT3.5/GPT4.0/ [懒人百宝箱](https://chat.lazaytools.top/) (后续会支持更多平台),能处理文本、语音和图片,通过插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
|
||||
|
||||
## 使用说明
|
||||
项目文档: [懒人百宝箱使用说明](https://gitee.com/alsritter/ChatGPT-On-CS)
|
||||
|
||||
## 项目地址
|
||||
|
||||
* [GitHub](https://github.com/lrhh123/ChatGPT-On-CS)
|
||||
* [Gitee](https://gitee.com/alsritter/ChatGPT-On-CS) (国内用户推荐)
|
||||
`}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Center, Text } from '@chakra-ui/react';
|
||||
import Loader from '../../components/Loader';
|
||||
|
||||
const FullScreenLoader = () => {
|
||||
return (
|
||||
<Center h="100vh" w="100vw" bg="white" flexDirection="column">
|
||||
<Text fontSize="lg" mt="4" zIndex={10} pb={40}>
|
||||
正在启动本地服务,请稍候...
|
||||
</Text>
|
||||
<Text fontSize="sm" zIndex={10}>
|
||||
(注意:安装后的第一次启动需要下载驱动,请耐心等待)
|
||||
</Text>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FullScreenLoader);
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Checkbox,
|
||||
Stack,
|
||||
HStack,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { updateRunner } from '../../services/platform/controller';
|
||||
import { useSystemStore } from '../../stores/useSystemStore';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import LogBox from './LogBox';
|
||||
|
||||
const DriverSettings = () => {
|
||||
const { toast } = useToast();
|
||||
const { driverSettings, setDriverSettings } = useSystemStore();
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on('refresh-config', () => {
|
||||
const { isPaused, isKeywordMatch } = driverSettings;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await updateRunner(isPaused, isKeywordMatch);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.remove('refresh-config');
|
||||
};
|
||||
}, [driverSettings, toast]);
|
||||
|
||||
const handleFormChange = (field: string) => (event: any) => {
|
||||
const { value, checked, type } = event.target;
|
||||
|
||||
const newDriverSettings = {
|
||||
...driverSettings,
|
||||
[field]: type === 'checkbox' ? checked : value,
|
||||
};
|
||||
|
||||
setDriverSettings(newDriverSettings);
|
||||
|
||||
if (field === 'isPaused') {
|
||||
if (checked) {
|
||||
toast({
|
||||
title: '已经暂停自动回复功能',
|
||||
status: 'warning',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '已经开启自动回复功能',
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
updateRunner(checked, driverSettings.isKeywordMatch);
|
||||
} else if (field === 'isKeywordMatch') {
|
||||
updateRunner(driverSettings.isPaused, checked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>连接</Tab>
|
||||
<Tab>日志</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Stack spacing={4}>
|
||||
<HStack width="full" alignItems="center">
|
||||
<Checkbox
|
||||
mr={4}
|
||||
isChecked={driverSettings.isPaused}
|
||||
onChange={handleFormChange('isPaused')}
|
||||
>
|
||||
<Tooltip label="暂停软件后,将不再自动回复消息">
|
||||
暂停软件
|
||||
</Tooltip>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={driverSettings.isKeywordMatch}
|
||||
onChange={handleFormChange('isKeywordMatch')}
|
||||
>
|
||||
<Tooltip label="将优先匹配关键词,未匹配的才去调用 GPT 接口">
|
||||
开启关键词匹配
|
||||
</Tooltip>
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Stack spacing={4}>
|
||||
<LogBox />
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverSettings;
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
Icon,
|
||||
HStack,
|
||||
Box,
|
||||
Switch,
|
||||
Select,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Flex,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FiHelpCircle } from 'react-icons/fi';
|
||||
import { AddIcon, DeleteIcon, AttachmentIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
getPlatformList,
|
||||
addReplyKeyword,
|
||||
updateReplyKeyword,
|
||||
} from '../../services/platform/controller';
|
||||
import MyTextarea from '../../components/MyTextarea';
|
||||
import Markdown from '../../components/Markdown';
|
||||
import MyModal from '../../components/MyModal';
|
||||
import { Reply, Platform } from '../../services/platform/platform';
|
||||
|
||||
interface EditKeywordProps {
|
||||
editKeyword: Reply | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEdit: () => void;
|
||||
}
|
||||
|
||||
const EditKeyword = ({
|
||||
editKeyword,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleEdit,
|
||||
}: EditKeywordProps) => {
|
||||
const toast = useToast();
|
||||
const [keywords, setKeywords] = useState<string[]>(
|
||||
editKeyword?.keyword.split('|') || [],
|
||||
);
|
||||
const [replyList, setReplyList] = useState<string[]>(
|
||||
editKeyword?.reply.split('[or]') || [],
|
||||
);
|
||||
|
||||
const [isGlobal, setIsGlobal] = useState<boolean>(false);
|
||||
const [ptf, setPtf] = useState<string>(editKeyword?.platform_id || '');
|
||||
const [newKeyword, setNewKeyword] = useState<string>('');
|
||||
const [newReply, setNewReply] = useState<string>('');
|
||||
const [startKeyword, setStartKeyword] = useState<string>('');
|
||||
const [endKeyword, setEndKeyword] = useState<string>('');
|
||||
const [currentPlatform, setCurrentPlatform] = useState<Platform | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editKeyword?.keyword) {
|
||||
setKeywords([]);
|
||||
} else {
|
||||
setKeywords(editKeyword?.keyword.split('|') || []);
|
||||
}
|
||||
|
||||
if (!editKeyword?.reply) {
|
||||
setReplyList([]);
|
||||
} else {
|
||||
setReplyList(editKeyword?.reply.split('[or]') || []);
|
||||
}
|
||||
|
||||
setPtf(editKeyword?.platform_id || '');
|
||||
if (editKeyword) {
|
||||
setIsGlobal(editKeyword.platform_id === '');
|
||||
}
|
||||
}, [editKeyword]);
|
||||
|
||||
const { data: platforms, isLoading: isPlatformsLoading } = useQuery(
|
||||
['platformList'],
|
||||
getPlatformList,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ptf) {
|
||||
setCurrentPlatform(
|
||||
platforms?.data.find((platform) => platform.id === ptf),
|
||||
);
|
||||
}
|
||||
}, [platforms, ptf]);
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
if (newKeyword) {
|
||||
setKeywords([...keywords, newKeyword]);
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFuzzyKeyword = () => {
|
||||
if (startKeyword && endKeyword) {
|
||||
const fuzzyKeyword = `${startKeyword}[and]${endKeyword}`;
|
||||
setKeywords([...keywords, fuzzyKeyword]);
|
||||
setStartKeyword('');
|
||||
setEndKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKeyword = (index: number) => {
|
||||
setKeywords(keywords.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAddReply = () => {
|
||||
if (newReply) {
|
||||
setReplyList([...replyList, newReply]);
|
||||
setNewReply('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReply = (index: number) => {
|
||||
setReplyList(replyList.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleInsertRandomChar = () => {
|
||||
setNewReply(`${newReply}[~]`);
|
||||
};
|
||||
|
||||
const handleInsertFile = () => {
|
||||
window.electron.ipcRenderer.sendMessage('select-file');
|
||||
window.electron.ipcRenderer.once('selected-file', (path) => {
|
||||
const selectedPath = path as string[];
|
||||
if (!selectedPath.length || !selectedPath[0]) return;
|
||||
setReplyList([...replyList, `[@]${selectedPath[0]}[/@]`]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeywordClick = (keyword: string, index: number) => {
|
||||
if (keyword.includes('[and]')) {
|
||||
const parts = keyword.split('[and]');
|
||||
setStartKeyword(parts[0]);
|
||||
setEndKeyword(parts[1]);
|
||||
} else {
|
||||
setNewKeyword(keyword);
|
||||
}
|
||||
handleDeleteKeyword(index);
|
||||
};
|
||||
|
||||
const handleReplyClick = (item: string, index: number) => {
|
||||
setNewReply(item);
|
||||
handleDeleteReply(index);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updatedReply = {
|
||||
...editKeyword,
|
||||
keyword: keywords.join('|'),
|
||||
reply: replyList.join('[or]'),
|
||||
};
|
||||
|
||||
if (!isGlobal) {
|
||||
updatedReply.platform_id = ptf;
|
||||
}
|
||||
|
||||
if (updatedReply.keyword === '') {
|
||||
throw new Error('关键词不能为空');
|
||||
}
|
||||
|
||||
if (updatedReply.reply === '') {
|
||||
throw new Error('回复内容不能为空');
|
||||
}
|
||||
|
||||
if (updatedReply.id) {
|
||||
await updateReplyKeyword(updatedReply);
|
||||
} else {
|
||||
await addReplyKeyword(updatedReply);
|
||||
}
|
||||
|
||||
handleEdit();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={editKeyword?.id ? '编辑关键词' : '新增关键词'}
|
||||
>
|
||||
<ModalBody>
|
||||
<HStack width="full" alignItems="center" my={3}>
|
||||
<Box width="30%">
|
||||
<Flex mt={3}>
|
||||
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
|
||||
全局关键词
|
||||
</Text>
|
||||
<Tooltip label="该关键词是否面向全部平台,否则请选择一个适用的平台">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box width="70%">
|
||||
<Switch
|
||||
isChecked={isGlobal}
|
||||
onChange={() => setIsGlobal(!isGlobal)}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{!isGlobal && (
|
||||
<HStack width="full" alignItems="center" mb={5}>
|
||||
<Box width="30%">
|
||||
<Text>选择平台:</Text>
|
||||
</Box>
|
||||
<Box width="70%">
|
||||
<Select
|
||||
value={ptf || ''}
|
||||
onChange={(e) => setPtf(e.target.value)}
|
||||
isDisabled={isPlatformsLoading}
|
||||
>
|
||||
{platforms?.data.map((platform) => (
|
||||
<option key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Flex mb="8px" mt="12px">
|
||||
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
|
||||
关键词设置
|
||||
</Text>
|
||||
<Tooltip label="设置关键词以匹配具体的回复,命中关键词的问题,不会使用 GPT 进行回答">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Stack direction="row" mb="4">
|
||||
<Input
|
||||
placeholder="新增关键词"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
/>
|
||||
<Tooltip label="新增一条关键词,可以使用 * 字符模糊匹配,如果要输入 * 字符,则使用 \* 代替">
|
||||
<Button onClick={handleAddKeyword} colorScheme="green">
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" mb="4">
|
||||
<Input
|
||||
placeholder="开始关键词"
|
||||
value={startKeyword}
|
||||
onChange={(e) => setStartKeyword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="结束关键词"
|
||||
value={endKeyword}
|
||||
onChange={(e) => setEndKeyword(e.target.value)}
|
||||
/>
|
||||
<Tooltip label="新增一个范围关键词,如果匹配上了开始关键词和结束关键词,也能匹配成功">
|
||||
<Button onClick={handleAddFuzzyKeyword} colorScheme="green">
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={4} wrap="wrap">
|
||||
{keywords.map((keyword, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
p="1"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
cursor="pointer"
|
||||
maxWidth="220px"
|
||||
onClick={() => handleKeywordClick(keyword, index)}
|
||||
style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}
|
||||
>
|
||||
{keyword}
|
||||
<IconButton
|
||||
ml={3}
|
||||
aria-label="Delete keyword"
|
||||
colorScheme="red"
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteKeyword(index);
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Flex mb="8px" mt="22px">
|
||||
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
|
||||
回复内容
|
||||
</Text>
|
||||
<Tooltip label="添加的多个关键词只要一个匹配上了,将会触发回复,如果有多个回复,将会随机选择一个回复。">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{currentPlatform && currentPlatform.desc && (
|
||||
<Box>
|
||||
<Markdown content={currentPlatform.desc} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip label="在拼多多平台等平台,是不允许每次重复一个回答的,所以可以插入一个随机符,以规避这个问题">
|
||||
<Button
|
||||
onClick={handleInsertRandomChar}
|
||||
mt="4"
|
||||
mr={4}
|
||||
colorScheme="teal"
|
||||
>
|
||||
插入随机符
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="有些无法发送文件或者图片的平台无法使用该文件">
|
||||
<Button
|
||||
leftIcon={<AttachmentIcon />}
|
||||
mt="4"
|
||||
onClick={handleInsertFile}
|
||||
colorScheme="orange"
|
||||
>
|
||||
插入文件
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Stack direction="row" mt="4">
|
||||
<MyTextarea
|
||||
mb="4"
|
||||
maxLength={200}
|
||||
placeholder="回复内容"
|
||||
value={newReply}
|
||||
onChange={(e) => setNewReply(e.target.value)}
|
||||
/>
|
||||
<Button onClick={handleAddReply} colorScheme="green">
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={4} wrap="wrap">
|
||||
{replyList.map((item, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
p="1"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
cursor="pointer"
|
||||
maxWidth="220px"
|
||||
onClick={() => handleReplyClick(item, index)}
|
||||
style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}
|
||||
>
|
||||
{item}
|
||||
<IconButton
|
||||
ml={3}
|
||||
aria-label="Delete reply"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteReply(index);
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditKeyword;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
HStack,
|
||||
TableContainer,
|
||||
Text,
|
||||
Button,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { useWebSocketContext } from '../../hooks/useWebSocketContext';
|
||||
import useGlobalStore from '../../stores/useGlobalStore';
|
||||
|
||||
const LogBox = () => {
|
||||
const { logs, clearLogs, addLog } = useGlobalStore();
|
||||
const { registerEventHandler, acknowledgeMessage } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = registerEventHandler((message) => {
|
||||
if (message.message === 'log_show') {
|
||||
acknowledgeMessage('log_show', message.event_id);
|
||||
if (message.data) {
|
||||
const log = message.data as {
|
||||
time: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
if (log) {
|
||||
addLog(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时注销事件处理器
|
||||
return () => unregister();
|
||||
}, [registerEventHandler, acknowledgeMessage]); // eslint-disable-line
|
||||
|
||||
const clearLog = () => {
|
||||
clearLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TableContainer maxH={'150px'} overflowY="scroll">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>时间</Th>
|
||||
<Th>
|
||||
<HStack width="full">
|
||||
<Text>内容</Text>
|
||||
<Button size="sm" onClick={clearLog}>
|
||||
清空全部日志
|
||||
</Button>
|
||||
</HStack>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{logs.map((log, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{log.time}</Td>
|
||||
<Td>{log.content}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LogBox);
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Image,
|
||||
Box,
|
||||
Skeleton,
|
||||
CheckboxGroup,
|
||||
Grid,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getPlatformList,
|
||||
updatePlatform,
|
||||
} from '../../services/platform/controller';
|
||||
import { Platform } from '../../services/platform/platform';
|
||||
import {
|
||||
PlatformTypeMap,
|
||||
PlatformTypeEnum,
|
||||
} from '../../services/platform/constant';
|
||||
import { useSystemStore } from '../../stores/useSystemStore';
|
||||
import defaultPlatformIcon from '../../../../assets/base/default-platform-icon.png';
|
||||
import analytics from '../../services/analytics/index_template';
|
||||
|
||||
const PlatformTabs = () => {
|
||||
const { data, isLoading } = useQuery(['platformList'], getPlatformList);
|
||||
const [firstLoad, setFirstLoad] = useState<boolean>(true);
|
||||
const { selectedPlatforms, setSelectedPlatforms } = useSystemStore();
|
||||
|
||||
// 处理数据加载完毕后的平台分组
|
||||
const groupedPlatforms = data?.data.reduce(
|
||||
(acc, platform) => {
|
||||
const type = String(platform.type) || 'other';
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(platform);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Platform[]>,
|
||||
);
|
||||
|
||||
// 第一次加载时需要更新一次后端
|
||||
useEffect(() => {
|
||||
if (selectedPlatforms && firstLoad) {
|
||||
updatePlatform(selectedPlatforms);
|
||||
setFirstLoad(false);
|
||||
}
|
||||
}, [firstLoad, setFirstLoad, selectedPlatforms]);
|
||||
|
||||
const handleCheckboxChange = async (selectedIds: string[]) => {
|
||||
const oldSelectedIds = selectedPlatforms;
|
||||
setSelectedPlatforms(selectedIds);
|
||||
await updatePlatform(selectedIds);
|
||||
|
||||
analytics.onEvent('$ModifySetting', {
|
||||
$NewValue: selectedIds,
|
||||
$OldValue: oldSelectedIds,
|
||||
$Type: 'platforms',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack>
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTabPanels = () => {
|
||||
const types = Object.keys(groupedPlatforms || {});
|
||||
return types.map((type) => (
|
||||
<TabPanel key={type}>
|
||||
<CheckboxGroup
|
||||
colorScheme="green"
|
||||
value={selectedPlatforms}
|
||||
onChange={handleCheckboxChange}
|
||||
>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||||
{groupedPlatforms?.[type]?.map((platform) => (
|
||||
<Box key={platform.id} display="flex" alignItems="center">
|
||||
{/* 显示平台图标,如果没有则显示默认图标 */}
|
||||
<Image
|
||||
src={platform.avatar || defaultPlatformIcon}
|
||||
fallbackSrc={defaultPlatformIcon}
|
||||
boxSize="25px"
|
||||
marginRight="12px"
|
||||
/>
|
||||
<Checkbox value={platform.id} isDisabled={!platform.impl}>
|
||||
{platform.name}
|
||||
</Checkbox>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</CheckboxGroup>
|
||||
</TabPanel>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs>
|
||||
<TabList>
|
||||
{Object.keys(groupedPlatforms || {}).map((type) => (
|
||||
<Tab key={type}>{PlatformTypeMap[type as PlatformTypeEnum]}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>{renderTabPanels()}</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformTabs;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Flex,
|
||||
TableContainer,
|
||||
useDisclosure,
|
||||
Text,
|
||||
Button,
|
||||
IconButton,
|
||||
Box,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Grid,
|
||||
} from '@chakra-ui/react';
|
||||
import { DeleteIcon, AddIcon, EditIcon } from '@chakra-ui/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import EditKeyword from './EditKeyword';
|
||||
import {
|
||||
getReplyList,
|
||||
deleteReplyKeyword,
|
||||
} from '../../services/platform/controller';
|
||||
import { Reply } from '../../services/platform/platform';
|
||||
|
||||
const ReplyKeyword = () => {
|
||||
const [keywords, setKeywords] = useState<Reply[]>([]);
|
||||
const [editKeyword, setEditKeyword] = useState<Reply | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery(
|
||||
['replyList'],
|
||||
() => {
|
||||
return getReplyList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
ptfId: '',
|
||||
});
|
||||
},
|
||||
{
|
||||
retry: () => {
|
||||
return true;
|
||||
},
|
||||
retryDelay: () => {
|
||||
return 1000;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setKeywords(data?.data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack>
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDoubleClick = (keyword: Reply) => {
|
||||
setEditKeyword(keyword);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
refetch();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteReplyKeyword(id);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
const newKeyword: Reply = {
|
||||
keyword: '',
|
||||
reply: '',
|
||||
mode: 'fuzzy',
|
||||
};
|
||||
setKeywords([...keywords, newKeyword]);
|
||||
setEditKeyword(newKeyword);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" mb={2}>
|
||||
<Text>编辑回复关键词</Text>
|
||||
<Flex alignItems="center">
|
||||
{' '}
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
color="white"
|
||||
bgGradient="linear(to-r, teal.500, green.500)"
|
||||
_hover={{
|
||||
bgGradient: 'linear(to-r, teal.300, green.300)',
|
||||
}}
|
||||
variant="solid"
|
||||
onClick={handleAddKeyword}
|
||||
>
|
||||
新增关键词
|
||||
</Button>
|
||||
{/* 在图标旁边添加文案,并设置左边距 */}
|
||||
</Flex>
|
||||
</Box>
|
||||
<TableContainer maxH={'300px'} overflowY="scroll">
|
||||
<Table variant="striped" size="sm" className="table-tiny">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>平台</Th>
|
||||
<Th>关键词</Th>
|
||||
<Th>回复内容</Th>
|
||||
<Th>操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{keywords.map((keyword) => (
|
||||
<Tr
|
||||
sx={{ height: '30px' }}
|
||||
key={keyword.id}
|
||||
onDoubleClick={() => handleDoubleClick(keyword)}
|
||||
>
|
||||
<Td>{keyword.ptf_name}</Td>
|
||||
<Td
|
||||
maxW="80px"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
{keyword.keyword}
|
||||
</Td>
|
||||
<Td
|
||||
maxW="150px"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
{keyword.reply}
|
||||
</Td>
|
||||
<Td>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<Tooltip label="删除">
|
||||
<IconButton
|
||||
size="xs" // 设置为最小尺寸
|
||||
fontSize="13px"
|
||||
colorScheme="red"
|
||||
aria-label="Delete keyword"
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => keyword.id && handleDelete(keyword.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="编辑">
|
||||
<IconButton
|
||||
size="xs" // 设置为最小尺寸
|
||||
fontSize="13px"
|
||||
colorScheme="blue"
|
||||
aria-label="Edit keyword"
|
||||
icon={<EditIcon />}
|
||||
onClick={() => {
|
||||
setEditKeyword(keyword);
|
||||
onOpen();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<EditKeyword
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
editKeyword={editKeyword}
|
||||
handleEdit={handleEdit}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyKeyword;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import PlatformTabs from './PlatformTabs';
|
||||
import DriverSettings from './DriverSettings';
|
||||
import ReplyKeyword from './ReplyKeyword';
|
||||
import analytics from '../../services/analytics/index_template';
|
||||
|
||||
const HomePage = () => {
|
||||
useEffect(() => {
|
||||
// 页面访问埋点
|
||||
analytics.onEvent('$PageView', {
|
||||
$PageName: 'home',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReplyKeyword />
|
||||
<Box mb={4}>
|
||||
<DriverSettings />
|
||||
</Box>
|
||||
<PlatformTabs />
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Image,
|
||||
Flex,
|
||||
Collapse,
|
||||
Button,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
Heading,
|
||||
Code,
|
||||
} from '@chakra-ui/react';
|
||||
import { TriangleDownIcon, TriangleUpIcon } from '@chakra-ui/icons';
|
||||
import { Message } from '../../services/platform/platform';
|
||||
|
||||
const SessionBox = ({
|
||||
index,
|
||||
sessionId,
|
||||
messages,
|
||||
}: {
|
||||
index: number;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}) => {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
|
||||
return (
|
||||
<Box p={5} shadow="md" borderWidth="1px">
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左对齐 */}
|
||||
<VStack align="flex-start">
|
||||
<Heading as="h6" size="xs" cursor="pointer" onClick={onToggle}>
|
||||
<Code>{`#${index}`}</Code>
|
||||
{` ${sessionId}`}
|
||||
</Heading>
|
||||
<Text fontSize="sm">
|
||||
时间:{messages[messages.length - 1].created_at}
|
||||
</Text>
|
||||
<Text fontSize="sm">消息数量:{messages.length}</Text>
|
||||
<Text fontSize="sm">平台:{messages[0].platform}</Text>
|
||||
{messages[0].goods_name && (
|
||||
<Text mt={2}>商品名称: {messages[0].goods_name}</Text>
|
||||
)}
|
||||
{messages[0].goods_avatar && (
|
||||
<Image
|
||||
src={messages[0].goods_avatar}
|
||||
alt="Goods Avatar"
|
||||
boxSize="50px"
|
||||
objectFit="cover"
|
||||
mt={2}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button size="sm" onClick={onToggle}>
|
||||
{isOpen ? <TriangleDownIcon /> : <TriangleUpIcon />}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<VStack mt={4} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Text fontWeight="bold">
|
||||
{message.role === 'user' ? '用户' : '客服'}:
|
||||
{message.msg_type === 'text' ? message.content : '图片消息'}
|
||||
{message.msg_type === 'image' && (
|
||||
<Image
|
||||
src={message.content}
|
||||
alt={message.content}
|
||||
boxSize="100px"
|
||||
objectFit="cover"
|
||||
mt={2}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SessionBox);
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Select,
|
||||
HStack,
|
||||
Input,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search2Icon, CloseIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
getPlatformList,
|
||||
getMessageList,
|
||||
} from '../../services/platform/controller';
|
||||
import SessionBox from './SessionBox';
|
||||
import analytics from '../../services/analytics/index_template';
|
||||
import { Message } from '../../services/platform/platform';
|
||||
|
||||
// 假设数据通过 props.sessions 传递
|
||||
const MsgList = () => {
|
||||
useEffect(() => {
|
||||
// 页面访问埋点
|
||||
analytics.onEvent('$PageView', {
|
||||
$PageName: 'msg_list',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [ptf, setPtf] = useState<string>('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page, setPage] = useState(1); // 新增分页状态
|
||||
const [sessions, setSessions] = useState<
|
||||
{
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}[]
|
||||
>([]); // 新增会话状态
|
||||
const { data: platforms, isLoading: isPlatformsLoading } = useQuery(
|
||||
['platformList'],
|
||||
getPlatformList,
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isMessagesLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
['messageList', page],
|
||||
() =>
|
||||
getMessageList({
|
||||
page,
|
||||
pageSize: 10,
|
||||
ptfId: ptf,
|
||||
keyword: searchTerm,
|
||||
startTime: startDate,
|
||||
endTime: endDate,
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true, // 保留旧数据,直到新数据加载完成
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.data) {
|
||||
// 参考下面的代码,懒加载取得的数据需要和旧的数据合并在一起重新分组
|
||||
const newSessions = Object.entries(data.data).map(
|
||||
([sessionId, messages]) => ({
|
||||
sessionId,
|
||||
messages,
|
||||
}),
|
||||
);
|
||||
setSessions((prevSessions) => [...prevSessions, ...newSessions]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isPlatformsLoading || isMessagesLoading) {
|
||||
return (
|
||||
<Stack m={10}>
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handlerSearch = () => {
|
||||
setPage(1); // 搜索时重置页码
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box m={3}>
|
||||
<HStack spacing={4} mt={3} align="stretch">
|
||||
<Input
|
||||
placeholder="Start Date (YYYY-MM-DD)"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="End Date (YYYY-MM-DD)"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack spacing={4} mt={3} align="stretch">
|
||||
<Select
|
||||
value={ptf || ''}
|
||||
onChange={(e) => setPtf(e.target.value)}
|
||||
isDisabled={isPlatformsLoading}
|
||||
>
|
||||
{platforms?.data.map((platform) => (
|
||||
<option key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
value={searchTerm}
|
||||
placeholder="Search messages"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<Button colorScheme="orange" variant="solid" onClick={handlerSearch}>
|
||||
<Search2Icon />
|
||||
</Button>
|
||||
|
||||
{(ptf || searchTerm || startDate || endDate) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPtf('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<VStack spacing={4} mt={5} align="stretch">
|
||||
{sessions.map((session, index) => (
|
||||
<SessionBox
|
||||
key={session.sessionId}
|
||||
index={index}
|
||||
sessionId={session.sessionId}
|
||||
messages={session.messages}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{data?.data && data.total > 0 && (
|
||||
<VStack spacing={4} mt={5} align="stretch">
|
||||
<Button
|
||||
mx={3}
|
||||
isLoading={isFetching}
|
||||
colorScheme="orange"
|
||||
variant="solid"
|
||||
onClick={() => setPage((prevPage) => prevPage + 1)}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MsgList);
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Text,
|
||||
Stack,
|
||||
VStack,
|
||||
Icon,
|
||||
Heading,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
HStack,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiHelpCircle } from 'react-icons/fi';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
getPlatformList,
|
||||
getPlatformSettings,
|
||||
updatePlatformSettings,
|
||||
} from '../../services/platform/controller';
|
||||
import {
|
||||
Platform,
|
||||
PlatformSettings as PS,
|
||||
} from '../../services/platform/platform';
|
||||
import analytics from '../../services/analytics/index_template';
|
||||
|
||||
const PlatformSettings = () => {
|
||||
useEffect(() => {
|
||||
// 页面访问埋点
|
||||
analytics.onEvent('$PageView', {
|
||||
$PageName: 'platform',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toast = useToast();
|
||||
const { data, isLoading: isPlatformsLoading } = useQuery(
|
||||
['platformList'],
|
||||
getPlatformList,
|
||||
);
|
||||
|
||||
const { data: settingsData, isLoading: isSettingsDataLoading } = useQuery(
|
||||
['platformSettings'],
|
||||
getPlatformSettings,
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (newSettings: PS) => {
|
||||
await updatePlatformSettings(newSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: '更新成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: '更新失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 初始化配置
|
||||
const [settings, setSettings] = useState<{
|
||||
[key: string]: PS;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData && data) {
|
||||
setSettings(
|
||||
data.data.reduce((acc, platform) => {
|
||||
let setting = settingsData.data.find(
|
||||
(item) => item.platform_id === platform.id,
|
||||
);
|
||||
|
||||
if (!setting)
|
||||
setting = {
|
||||
platform_id: platform.id,
|
||||
openai_url: '',
|
||||
api_key: '',
|
||||
prompt: '',
|
||||
active: false,
|
||||
};
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[platform.id]: {
|
||||
openai_url: setting.openai_url,
|
||||
api_key: setting.api_key,
|
||||
prompt: setting.prompt,
|
||||
active: setting.active,
|
||||
},
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
}, [settingsData, data]);
|
||||
|
||||
const handleInputChange = (id: string, field: string) => (e: any) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
[id]: {
|
||||
...settings[id],
|
||||
[field]: e.target.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleActiveChange = (id: string) => (e: any) => {
|
||||
if (!settings) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
[id]: {
|
||||
...settings[id],
|
||||
active: e.target.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (platform: Platform) => (e: any) => {
|
||||
e.preventDefault();
|
||||
if (!settings) return;
|
||||
updateMutation.mutate({
|
||||
...settings[platform.id],
|
||||
platform_id: platform.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (isPlatformsLoading || isSettingsDataLoading) {
|
||||
return (
|
||||
<Stack m={10}>
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={5} shadow="md" borderWidth="1px">
|
||||
<VStack mb={4} align="flex-start">
|
||||
<Heading as="h4" size="md" mb={4}>
|
||||
平台设置
|
||||
</Heading>
|
||||
<Text>独立设置各个平台所使用的 OpenAI 配置</Text>
|
||||
</VStack>
|
||||
<Accordion allowMultiple>
|
||||
{data &&
|
||||
settings &&
|
||||
data.data
|
||||
.filter((item) => item.impl)
|
||||
.map((platform) => (
|
||||
<AccordionItem key={platform.name}>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{platform.name}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<form onSubmit={handleSubmit(platform)}>
|
||||
<FormControl display="flex" alignItems="center" mb="3">
|
||||
<FormLabel htmlFor="active" mb="0">
|
||||
启用配置
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="active"
|
||||
isChecked={settings[platform.id]?.active}
|
||||
onChange={handleActiveChange(platform.id)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<HStack>
|
||||
<FormLabel>OpenAI 代理地址</FormLabel>
|
||||
<Tooltip label="设置后则不使用全局设置的 OpenAI 地址">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings[platform.id]?.openai_url}
|
||||
onChange={handleInputChange(platform.id, 'openai_url')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired>
|
||||
<FormLabel>密钥</FormLabel>
|
||||
<Input
|
||||
value={settings[platform.id]?.api_key}
|
||||
onChange={handleInputChange(platform.id, 'api_key')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<HStack>
|
||||
<Text>提示词</Text>
|
||||
<Tooltip label="当前涉及到复杂的知识库需求时,可以使用懒人百宝箱或者 FastGPT 这类拓展知识库工具">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Textarea
|
||||
value={settings[platform.id]?.prompt}
|
||||
onChange={handleInputChange(platform.id, 'prompt')}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button mt={4} colorScheme="blue" type="submit">
|
||||
更新
|
||||
</Button>
|
||||
</form>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PlatformSettings);
|
||||
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import { FiHelpCircle, FiFolder } from 'react-icons/fi';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Slider,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
Text,
|
||||
useToast,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { useSettings } from './SettingsContext';
|
||||
|
||||
const CustomerServiceSettings = () => {
|
||||
const toast = useToast();
|
||||
const { customerServiceSettings, setCustomerServiceSettings } = useSettings();
|
||||
|
||||
const selectFolderPath = () => {
|
||||
window.electron.ipcRenderer.sendMessage('select-directory');
|
||||
window.electron.ipcRenderer.once('selected-directory', (path) => {
|
||||
const selectedPath = path as string[];
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
folderPath: selectedPath[0],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openSelectedFolder = () => {
|
||||
if (customerServiceSettings.folderPath) {
|
||||
window.electron.ipcRenderer.sendMessage(
|
||||
'open-directory',
|
||||
customerServiceSettings.folderPath,
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
title: '未选择文件夹',
|
||||
description: '请先选择一个文件夹路径。',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box
|
||||
flex="1"
|
||||
textAlign="left"
|
||||
fontSize={'large'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
客服设置
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<Checkbox
|
||||
isChecked={customerServiceSettings.extractPhone}
|
||||
onChange={(e) =>
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
extractPhone: e.target.checked,
|
||||
})
|
||||
}
|
||||
mr={4}
|
||||
>
|
||||
提取手机号
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={customerServiceSettings.extractProduct}
|
||||
onChange={(e) => {
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
extractProduct: e.target.checked,
|
||||
});
|
||||
}}
|
||||
mr={4}
|
||||
>
|
||||
提取咨询商品名
|
||||
</Checkbox>
|
||||
|
||||
<FormControl mt={3}>
|
||||
<FormLabel>提取内容存储路径</FormLabel>
|
||||
<Flex>
|
||||
<Input
|
||||
value={customerServiceSettings.folderPath}
|
||||
isReadOnly
|
||||
placeholder="选择文件夹路径"
|
||||
/>
|
||||
<Button ml={2} onClick={selectFolderPath}>
|
||||
选择
|
||||
</Button>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
onClick={openSelectedFolder}
|
||||
leftIcon={<FiFolder />}
|
||||
my={3}
|
||||
w={'100%'}
|
||||
>
|
||||
打开本地文件夹
|
||||
</Button>
|
||||
|
||||
<Text mb="8px">
|
||||
回复等待时间(单位秒): {customerServiceSettings.replySpeed}秒
|
||||
</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={customerServiceSettings.replySpeed}
|
||||
onChange={(value) =>
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
replySpeed: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
|
||||
<Flex mt={3}>
|
||||
<Text mb="8px" mr={3}>
|
||||
上下文消息数:{' '}
|
||||
{customerServiceSettings.mergeUnprocessedMessagesCount}
|
||||
</Text>
|
||||
<Tooltip label="使用 GPT 回复会指定的消息数量传递给 GPT 去生成下一条回复,数量设置的越大回复的速度越慢">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={customerServiceSettings.mergeUnprocessedMessagesCount}
|
||||
onChange={(value) =>
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
mergeUnprocessedMessagesCount: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
|
||||
<Flex mt={3}>
|
||||
<Text mb="8px" mr={3}>
|
||||
等待人工间隔: {customerServiceSettings.manualInterventionInterval}
|
||||
秒
|
||||
</Text>
|
||||
<Tooltip label="多长时间没有回复则通知人工接入,单位秒">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Slider
|
||||
min={10}
|
||||
max={180}
|
||||
step={10}
|
||||
value={customerServiceSettings.manualInterventionInterval}
|
||||
onChange={(value) =>
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
manualInterventionInterval: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
|
||||
{/* <Flex mt={3}>
|
||||
<Text mb="8px" mr={3}>
|
||||
关键字触发间隔: {customerServiceSettings.keywordTriggerInterval}秒
|
||||
</Text>
|
||||
<Tooltip label="关键字触发间隔">
|
||||
<Box color={'gray.500'}>
|
||||
<Icon as={FiHelpCircle} w={6} h={6} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Slider
|
||||
min={10}
|
||||
max={180}
|
||||
step={10}
|
||||
value={customerServiceSettings.keywordTriggerInterval}
|
||||
onChange={(value) =>
|
||||
setCustomerServiceSettings({
|
||||
...customerServiceSettings,
|
||||
keywordTriggerInterval: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider> */}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerServiceSettings;
|
||||
@@ -0,0 +1,212 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Input,
|
||||
Select,
|
||||
Box,
|
||||
Slider,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
HStack,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
|
||||
import { useSettings } from './SettingsContext';
|
||||
|
||||
const GptSettings = () => {
|
||||
const { gptSettings, setGptSettings } = useSettings();
|
||||
const [show, setShow] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box
|
||||
flex="1"
|
||||
textAlign="left"
|
||||
fontSize={'large'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
GPT 设置
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel htmlFor="useLazyTools" mb="0">
|
||||
使用懒人百宝箱
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="useLazyTools"
|
||||
isChecked={gptSettings.useLazyTools}
|
||||
onChange={(e) =>
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
useLazyTools: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!gptSettings.useLazyTools && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="gptAddress" mt="8px">
|
||||
GPT 地址设置
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="gptAddress"
|
||||
value={gptSettings.gptAddress}
|
||||
onChange={(e) =>
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
gptAddress: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="model" mt="8px">
|
||||
使用的模型
|
||||
</FormLabel>
|
||||
<Select
|
||||
id="model"
|
||||
value={gptSettings.model}
|
||||
onChange={(e) =>
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
model: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="gpt3">GPT-3</option>
|
||||
<option value="gpt3.5">GPT-3.5</option>
|
||||
<option value="gpt4">GPT-4</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="temperature" mt="8px">
|
||||
Temperature(可选): {gptSettings.temperature}
|
||||
</FormLabel>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
id="temperature"
|
||||
value={gptSettings.temperature}
|
||||
onChange={(value) => {
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
temperature: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="topP" mt="8px">
|
||||
Top P(可选): {gptSettings.topP}
|
||||
</FormLabel>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
id="topP"
|
||||
value={gptSettings.topP}
|
||||
onChange={(value) => {
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
topP: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack mb="4" mt="8px">
|
||||
<FormLabel htmlFor="stream" width="30%">
|
||||
Stream(可选)
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="stream"
|
||||
isChecked={gptSettings.stream}
|
||||
onChange={(e) => {
|
||||
setGptSettings({
|
||||
...gptSettings,
|
||||
stream: e.target.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="apiKey">API Key</FormLabel>
|
||||
{gptSettings.useLazyTools ? (
|
||||
<Input
|
||||
id="lazyKey"
|
||||
type="password"
|
||||
value={gptSettings.apiKey}
|
||||
onChange={(e) =>
|
||||
setGptSettings({ ...gptSettings, apiKey: e.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<InputGroup size="md">
|
||||
<Input
|
||||
id="apiKey"
|
||||
pr="4.5rem"
|
||||
type={show ? 'text' : 'password'}
|
||||
value={gptSettings.apiKey}
|
||||
onChange={(e) => {
|
||||
setGptSettings({ ...gptSettings, apiKey: e.target.value });
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button
|
||||
h="1.75rem"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShow(!show);
|
||||
}}
|
||||
>
|
||||
{show ? <ViewIcon /> : <ViewOffIcon />}
|
||||
</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)}
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default GptSettings;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
CustomerServiceSettingsForm,
|
||||
GptSettingsForm,
|
||||
} from '../../services/platform/platform';
|
||||
|
||||
// 定义Context的类型
|
||||
interface SettingsContextType {
|
||||
customerServiceSettings: CustomerServiceSettingsForm;
|
||||
setCustomerServiceSettings: (settings: CustomerServiceSettingsForm) => void;
|
||||
gptSettings: GptSettingsForm;
|
||||
setGptSettings: (settings: GptSettingsForm) => void;
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [customerServiceSettings, setCustomerServiceSettings] =
|
||||
useState<CustomerServiceSettingsForm>({
|
||||
extractPhone: false,
|
||||
extractProduct: false,
|
||||
folderPath: '',
|
||||
replySpeed: 0,
|
||||
mergeUnprocessedMessagesCount: 0,
|
||||
manualInterventionInterval: 0,
|
||||
});
|
||||
|
||||
const [gptSettings, setGptSettings] = useState<GptSettingsForm>({
|
||||
useLazyTools: false,
|
||||
gptAddress: '',
|
||||
apiKey: '',
|
||||
lazyKey: '',
|
||||
model: '',
|
||||
temperature: 0,
|
||||
topP: 0,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
// 使用useMemo来记忆Provider的value
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
customerServiceSettings,
|
||||
setCustomerServiceSettings,
|
||||
gptSettings,
|
||||
setGptSettings,
|
||||
}),
|
||||
[customerServiceSettings, gptSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook用于子组件访问Context
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSettings must be used within a SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box, Button, Stack, useToast, Skeleton } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import GptSettings from './GptSettings';
|
||||
import CustomerServiceSettings from './CustomerServiceSettings';
|
||||
import { getConfig, updateConfig } from '../../services/platform/controller';
|
||||
import { useSettings } from './SettingsContext';
|
||||
import analytics from '../../services/analytics/index_template';
|
||||
|
||||
const SettingsPage = () => {
|
||||
useEffect(() => {
|
||||
// 页面访问埋点
|
||||
analytics.onEvent('$PageView', {
|
||||
$PageName: 'settings',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
customerServiceSettings,
|
||||
setCustomerServiceSettings,
|
||||
gptSettings,
|
||||
setGptSettings,
|
||||
} = useSettings();
|
||||
const toast = useToast();
|
||||
const { data, isLoading } = useQuery(['config'], getConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !isLoading) {
|
||||
setGptSettings({
|
||||
useLazyTools: data.data.use_lazy || false,
|
||||
gptAddress: data.data.gpt_base_url || '',
|
||||
model: data.data.gpt_model || '',
|
||||
temperature: data.data.gpt_temperature || 0.7,
|
||||
apiKey: data.data.gpt_key || '',
|
||||
topP: data.data.gpt_top_p || 0.75,
|
||||
stream: data.data.stream || false,
|
||||
lazyKey: data.data.lazy_key || '',
|
||||
});
|
||||
setCustomerServiceSettings({
|
||||
extractPhone: data.data.extract_phone || false,
|
||||
extractProduct: data.data.extract_product || false,
|
||||
folderPath: data.data.save_path || '',
|
||||
replySpeed: data.data.reply_speed || 0,
|
||||
mergeUnprocessedMessagesCount: data.data.merged_message_num || 7,
|
||||
manualInterventionInterval: data.data.wait_humans_time || 60,
|
||||
});
|
||||
}
|
||||
}, [isLoading, data, setGptSettings, setCustomerServiceSettings]);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
await updateConfig({
|
||||
extract_phone: customerServiceSettings.extractPhone,
|
||||
extract_product: customerServiceSettings.extractProduct,
|
||||
save_path: customerServiceSettings.folderPath,
|
||||
reply_speed: customerServiceSettings.replySpeed,
|
||||
merged_message_num:
|
||||
customerServiceSettings.mergeUnprocessedMessagesCount,
|
||||
wait_humans_time: customerServiceSettings.manualInterventionInterval,
|
||||
gpt_base_url: gptSettings.gptAddress,
|
||||
gpt_key: gptSettings.apiKey,
|
||||
gpt_model: gptSettings.model,
|
||||
gpt_temperature: gptSettings.temperature,
|
||||
gpt_top_p: gptSettings.topP,
|
||||
stream: gptSettings.stream,
|
||||
use_lazy: gptSettings.useLazyTools,
|
||||
lazy_key: gptSettings.lazyKey,
|
||||
});
|
||||
toast({
|
||||
title: '保存成功',
|
||||
status: 'success',
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack m={10}>
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
<Skeleton height="20px" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={4}>
|
||||
<Stack spacing={4}>
|
||||
<CustomerServiceSettings />
|
||||
<GptSettings />
|
||||
<Button colorScheme="blue" onClick={handleSaveSettings}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ElectronHandler } from '../main/preload';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Window {
|
||||
electron: ElectronHandler;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
import agconnect from '@agconnect/api';
|
||||
import '@agconnect/instance';
|
||||
import '@hw-hmscore/analytics-web';
|
||||
|
||||
const agConnectConfig = {
|
||||
// TODO: 请替换为您的应用 ID
|
||||
// 这里使用的是华为的服务用于打点
|
||||
};
|
||||
|
||||
// 初始化分析实例
|
||||
agconnect.instance().configInstance(agConnectConfig);
|
||||
const analytics = agconnect.analytics();
|
||||
|
||||
export default analytics;
|
||||
@@ -0,0 +1,153 @@
|
||||
import axios, {
|
||||
Method,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from 'axios';
|
||||
|
||||
interface ConfigType {
|
||||
headers?: { [key: string]: string };
|
||||
hold?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
interface ResponseDataType {
|
||||
code: number;
|
||||
message: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求开始
|
||||
*/
|
||||
function requestStart(
|
||||
config: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求成功,检查请求头
|
||||
*/
|
||||
function responseSuccess(response: AxiosResponse<ResponseDataType>) {
|
||||
return response;
|
||||
}
|
||||
/**
|
||||
* 响应数据检查
|
||||
*/
|
||||
function checkRes(data: ResponseDataType) {
|
||||
if (data === undefined) {
|
||||
return Promise.reject('服务器异常');
|
||||
}
|
||||
if (data?.code && (data.code < 200 || data.code >= 400)) {
|
||||
return Promise.reject(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应错误
|
||||
*/
|
||||
function responseError(err: any) {
|
||||
if (!err) {
|
||||
return Promise.reject({ message: '未知错误' });
|
||||
}
|
||||
|
||||
// 检查网络错误(无响应)
|
||||
if (err.message === 'Network Error') {
|
||||
return Promise.reject({ message: '服务还在启动,请稍后尝试' });
|
||||
}
|
||||
|
||||
// 检查超时错误
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
return Promise.reject({ message: '请求超时,请稍后再试' });
|
||||
}
|
||||
|
||||
// 检查是否有响应体和状态码
|
||||
if (err.response) {
|
||||
// 这里可以根据 err.response.status 进行更详细的错误处理
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
// 对于其他类型的错误,直接返回
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
/* 创建请求实例 */
|
||||
const instance = axios.create({
|
||||
timeout: 60000, // 超时时间
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
|
||||
/* 请求拦截 */
|
||||
instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
|
||||
/* 响应拦截 */
|
||||
instance.interceptors.response.use(responseSuccess, (err) =>
|
||||
Promise.reject(err),
|
||||
);
|
||||
|
||||
export function request(
|
||||
url: string,
|
||||
data: any,
|
||||
config: ConfigType,
|
||||
method: Method,
|
||||
): any {
|
||||
/* 去空 */
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (data[key] === null || data[key] === undefined) {
|
||||
delete data[key]; // 如果属性值为 null 或 undefined,则删除该属性
|
||||
}
|
||||
});
|
||||
|
||||
return instance
|
||||
.request({
|
||||
baseURL: `http://127.0.0.1:${window.electron.getPort()}`,
|
||||
url,
|
||||
method,
|
||||
data: ['POST', 'PUT'].includes(method) ? data : null,
|
||||
params: !['POST', 'PUT'].includes(method) ? data : null,
|
||||
...config, // custom config
|
||||
})
|
||||
.then((res) => checkRes(res.data))
|
||||
.catch((err) => responseError(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* api请求方式
|
||||
* @param {String} url
|
||||
* @param {Any} params
|
||||
* @param {Object} config
|
||||
* @returns
|
||||
*/
|
||||
export function GET<T = undefined>(
|
||||
url: string,
|
||||
params = {},
|
||||
config: ConfigType = {},
|
||||
): Promise<T> {
|
||||
return request(url, params, config, 'GET');
|
||||
}
|
||||
|
||||
export function POST<T = undefined>(
|
||||
url: string,
|
||||
data = {},
|
||||
config: ConfigType = {},
|
||||
): Promise<T> {
|
||||
return request(url, data, config, 'POST');
|
||||
}
|
||||
|
||||
export function PUT<T = undefined>(
|
||||
url: string,
|
||||
data = {},
|
||||
config: ConfigType = {},
|
||||
): Promise<T> {
|
||||
return request(url, data, config, 'PUT');
|
||||
}
|
||||
|
||||
export function DELETE<T = undefined>(
|
||||
url: string,
|
||||
data = {},
|
||||
config: ConfigType = {},
|
||||
): Promise<T> {
|
||||
return request(url, data, config, 'DELETE');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export enum PlatformTypeEnum {
|
||||
HOT = 'HOT',
|
||||
E_COMMERCE = 'E_COMMERCE',
|
||||
RECRUIT = 'RECRUIT',
|
||||
OTHER = 'OTHER',
|
||||
LAW = 'LAW',
|
||||
}
|
||||
|
||||
export const PlatformTypeMap = {
|
||||
[PlatformTypeEnum.HOT]: '热门',
|
||||
[PlatformTypeEnum.E_COMMERCE]: '电商',
|
||||
[PlatformTypeEnum.RECRUIT]: '招聘',
|
||||
[PlatformTypeEnum.LAW]: '法律咨询',
|
||||
[PlatformTypeEnum.OTHER]: '其他',
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Platform, Reply, Config, Message } from './platform';
|
||||
import { DELETE, GET, POST } from '../common/api/request';
|
||||
|
||||
export async function getPlatformList() {
|
||||
const data = await GET<{
|
||||
data: Platform[];
|
||||
}>('/api/v1/base/platform/all');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getActivePlatformList() {
|
||||
const data = await GET<{
|
||||
data: Platform[];
|
||||
}>('/api/v1/base/platform/active');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePlatform(ids: string[]) {
|
||||
await POST('/api/v1/base/platform', ids);
|
||||
}
|
||||
|
||||
export async function updateRunner(isPaused: boolean, isKeywordMatch: boolean) {
|
||||
await POST('/api/v1/base/runner', {
|
||||
is_paused: isPaused,
|
||||
is_keyword_match: isKeywordMatch,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReplyList({
|
||||
page,
|
||||
pageSize,
|
||||
ptfId,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
ptfId?: string;
|
||||
}) {
|
||||
const data = await GET<{
|
||||
total: number;
|
||||
data: Reply[];
|
||||
}>('/api/v1/reply/list', {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
platform_id: ptfId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addReplyKeyword(keyword: Reply) {
|
||||
await POST('/api/v1/reply/create', keyword);
|
||||
}
|
||||
|
||||
export async function updateReplyKeyword(keyword: Reply) {
|
||||
await POST('/api/v1/reply/update', keyword);
|
||||
}
|
||||
|
||||
export async function deleteReplyKeyword(id: number) {
|
||||
await DELETE('/api/v1/reply/delete', { id });
|
||||
}
|
||||
|
||||
export async function getConfig() {
|
||||
const data = await GET<{
|
||||
data: Config;
|
||||
}>('/api/v1/base/settings');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateConfig(config: Config) {
|
||||
await POST('/api/v1/base/settings', config);
|
||||
}
|
||||
|
||||
export async function getMessageList({
|
||||
page,
|
||||
pageSize,
|
||||
ptfId,
|
||||
keyword,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
ptfId?: string;
|
||||
keyword?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}) {
|
||||
const data = await GET<{
|
||||
total: number;
|
||||
data: {
|
||||
[key: string]: Message[];
|
||||
};
|
||||
}>('/api/v1/msg/list', {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
platform_id: ptfId,
|
||||
keyword,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPlatformSettings() {
|
||||
const data = await GET<{
|
||||
data: {
|
||||
platform_id: string;
|
||||
openai_url: string;
|
||||
api_key: string;
|
||||
prompt: string;
|
||||
active: boolean;
|
||||
}[];
|
||||
}>('/api/v1/base/platform/settings');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePlatformSettings(settings: {
|
||||
openai_url: string;
|
||||
api_key: string;
|
||||
prompt: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
await POST('/api/v1/base/platform/settings', settings);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PlatformTypeEnum } from './constant';
|
||||
|
||||
export interface Platform {
|
||||
id: string;
|
||||
name: string;
|
||||
impl: boolean;
|
||||
type?: PlatformTypeEnum;
|
||||
urls?: string[];
|
||||
avatar?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface PlatformSettings {
|
||||
platform_id: string;
|
||||
openai_url: string;
|
||||
api_key: string;
|
||||
prompt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id?: number;
|
||||
platform_id?: string;
|
||||
keyword: string;
|
||||
reply: string;
|
||||
mode?: 'fuzzy' | 'exact';
|
||||
ptf_name?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
session_id: number;
|
||||
role: string;
|
||||
created_at: string;
|
||||
content: string;
|
||||
msg_type: string;
|
||||
platform_id: string;
|
||||
platform: string;
|
||||
goods_avatar: string | null;
|
||||
goods_name: string | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
extract_phone: boolean; // 提取手机号
|
||||
extract_product: boolean; // 提取商品
|
||||
save_path?: string; // 保存路径
|
||||
reply_speed: number; // 回复速度
|
||||
merged_message_num: number; // 合并消息数量
|
||||
wait_humans_time: number; // 等待人工时间
|
||||
|
||||
gpt_base_url?: string; // GPT服务地址
|
||||
gpt_key?: string; // GPT服务key
|
||||
gpt_model?: string; // GPT服务模型
|
||||
gpt_temperature?: number; // GPT服务温度
|
||||
gpt_top_p?: number; // GPT服务top_p
|
||||
stream?: boolean; // 是否开启stream
|
||||
|
||||
use_lazy?: boolean; // 是否使用懒人百宝箱
|
||||
lazy_key?: string; // 懒人百宝箱 key
|
||||
}
|
||||
|
||||
export interface CustomerServiceSettingsForm {
|
||||
extractPhone: boolean;
|
||||
extractProduct: boolean;
|
||||
folderPath: string;
|
||||
replySpeed: number;
|
||||
mergeUnprocessedMessagesCount: number;
|
||||
manualInterventionInterval: number;
|
||||
}
|
||||
|
||||
export interface GptSettingsForm {
|
||||
useLazyTools: boolean;
|
||||
gptAddress: string;
|
||||
apiKey: string;
|
||||
lazyKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
topP: number;
|
||||
stream: boolean;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const getVersionInfo = async (currentVersion: string) => {
|
||||
let result = null;
|
||||
try {
|
||||
const data = await axios.get<{
|
||||
version: string;
|
||||
url: string;
|
||||
description: string;
|
||||
}>('https://update.wizgadg.top/check-update/chatgpt-on-cs');
|
||||
|
||||
result = data.data;
|
||||
// 检查版本是否需要更新
|
||||
if (result.version === currentVersion) {
|
||||
result = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// 定义你的日志对象类型
|
||||
interface LogObj {
|
||||
time: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 定义 Store 的状态和方法
|
||||
interface GlobalStore {
|
||||
logs: LogObj[];
|
||||
addLog: (log: LogObj) => void;
|
||||
clearLogs: () => void;
|
||||
}
|
||||
|
||||
// 创建 Store
|
||||
const useGlobalStore = create<GlobalStore>((set) => ({
|
||||
logs: [], // 初始日志数组为空
|
||||
|
||||
// 添加日志的方法
|
||||
addLog: (log) =>
|
||||
set((state) => ({
|
||||
logs: [...state.logs, log].slice(-50), // 保持日志数组最多 50 条,如果超出则移除最旧的
|
||||
})),
|
||||
|
||||
// 清空日志的方法
|
||||
clearLogs: () =>
|
||||
set(() => ({
|
||||
logs: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useGlobalStore;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
const electronStore = {
|
||||
getItem: (key: string) => {
|
||||
const value = window.electron.store.get(key);
|
||||
try {
|
||||
return JSON.parse(value); // 确保字符串被正确解析为对象
|
||||
} catch (error) {
|
||||
return null; // 解析失败时返回null或合理的默认值
|
||||
}
|
||||
},
|
||||
setItem: (key: string, value: any) => {
|
||||
window.electron.store.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
window.electron.store.remove(key);
|
||||
},
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedPlatforms: string[];
|
||||
setSelectedPlatforms: (ids: string[]) => void;
|
||||
driverSettings: {
|
||||
isPaused: boolean;
|
||||
isKeywordMatch: boolean;
|
||||
};
|
||||
setDriverSettings: (settings: {
|
||||
isPaused: boolean;
|
||||
isKeywordMatch: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export const useSystemStore = create<State>()(
|
||||
devtools(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
selectedPlatforms: [],
|
||||
setSelectedPlatforms: (ids: string[]) => {
|
||||
set((state) => {
|
||||
state.selectedPlatforms = ids;
|
||||
});
|
||||
},
|
||||
driverSettings: {
|
||||
isPaused: true,
|
||||
runMode: 'AUTO_SWITCH',
|
||||
isKeywordMatch: true,
|
||||
},
|
||||
setDriverSettings: (settings) => {
|
||||
set((state) => {
|
||||
state.driverSettings = settings;
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: 'globalStore',
|
||||
storage: createJSONStorage(() => electronStore),
|
||||
partialize: (state) => ({
|
||||
selectedPlatforms: state.selectedPlatforms,
|
||||
driverSettings: state.driverSettings,
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,164 @@
|
||||
const colors = {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
black: '#000000',
|
||||
white: '#FFFFFF',
|
||||
|
||||
whiteAlpha: {
|
||||
50: 'rgba(255, 255, 255, 0.04)',
|
||||
100: 'rgba(255, 255, 255, 0.06)',
|
||||
200: 'rgba(255, 255, 255, 0.08)',
|
||||
300: 'rgba(255, 255, 255, 0.16)',
|
||||
400: 'rgba(255, 255, 255, 0.24)',
|
||||
500: 'rgba(255, 255, 255, 0.36)',
|
||||
600: 'rgba(255, 255, 255, 0.48)',
|
||||
700: 'rgba(255, 255, 255, 0.64)',
|
||||
800: 'rgba(255, 255, 255, 0.80)',
|
||||
900: 'rgba(255, 255, 255, 0.92)',
|
||||
},
|
||||
|
||||
blackAlpha: {
|
||||
50: 'rgba(0, 0, 0, 0.04)',
|
||||
100: 'rgba(0, 0, 0, 0.06)',
|
||||
200: 'rgba(0, 0, 0, 0.08)',
|
||||
300: 'rgba(0, 0, 0, 0.16)',
|
||||
400: 'rgba(0, 0, 0, 0.24)',
|
||||
500: 'rgba(0, 0, 0, 0.36)',
|
||||
600: 'rgba(0, 0, 0, 0.48)',
|
||||
700: 'rgba(0, 0, 0, 0.64)',
|
||||
800: 'rgba(0, 0, 0, 0.80)',
|
||||
900: 'rgba(0, 0, 0, 0.92)',
|
||||
},
|
||||
|
||||
gray: {
|
||||
50: '#F7FAFC',
|
||||
100: '#EDF2F7',
|
||||
200: '#E2E8F0',
|
||||
300: '#CBD5E0',
|
||||
400: '#A0AEC0',
|
||||
500: '#718096',
|
||||
600: '#4A5568',
|
||||
700: '#2D3748',
|
||||
800: '#1A202C',
|
||||
900: '#171923',
|
||||
},
|
||||
|
||||
red: {
|
||||
50: '#FFF5F5',
|
||||
100: '#FED7D7',
|
||||
200: '#FEB2B2',
|
||||
300: '#FC8181',
|
||||
400: '#F56565',
|
||||
500: '#E53E3E',
|
||||
600: '#C53030',
|
||||
700: '#9B2C2C',
|
||||
800: '#822727',
|
||||
900: '#63171B',
|
||||
},
|
||||
|
||||
orange: {
|
||||
50: '#FFFAF0',
|
||||
100: '#FEEBC8',
|
||||
200: '#FBD38D',
|
||||
300: '#F6AD55',
|
||||
400: '#ED8936',
|
||||
500: '#DD6B20',
|
||||
600: '#C05621',
|
||||
700: '#9C4221',
|
||||
800: '#7B341E',
|
||||
900: '#652B19',
|
||||
},
|
||||
|
||||
yellow: {
|
||||
50: '#FFFFF0',
|
||||
100: '#FEFCBF',
|
||||
200: '#FAF089',
|
||||
300: '#F6E05E',
|
||||
400: '#ECC94B',
|
||||
500: '#D69E2E',
|
||||
600: '#B7791F',
|
||||
700: '#975A16',
|
||||
800: '#744210',
|
||||
900: '#5F370E',
|
||||
},
|
||||
|
||||
green: {
|
||||
50: '#F0FFF4',
|
||||
100: '#C6F6D5',
|
||||
200: '#9AE6B4',
|
||||
300: '#68D391',
|
||||
400: '#48BB78',
|
||||
500: '#38A169',
|
||||
600: '#2F855A',
|
||||
700: '#276749',
|
||||
800: '#22543D',
|
||||
900: '#1C4532',
|
||||
},
|
||||
|
||||
teal: {
|
||||
50: '#E6FFFA',
|
||||
100: '#B2F5EA',
|
||||
200: '#81E6D9',
|
||||
300: '#4FD1C5',
|
||||
400: '#38B2AC',
|
||||
500: '#319795',
|
||||
600: '#2C7A7B',
|
||||
700: '#285E61',
|
||||
800: '#234E52',
|
||||
900: '#1D4044',
|
||||
},
|
||||
|
||||
blue: {
|
||||
50: '#ebf8ff',
|
||||
100: '#bee3f8',
|
||||
200: '#90cdf4',
|
||||
300: '#63b3ed',
|
||||
400: '#4299e1',
|
||||
500: '#3182ce',
|
||||
600: '#2b6cb0',
|
||||
700: '#2c5282',
|
||||
800: '#2a4365',
|
||||
900: '#1A365D',
|
||||
},
|
||||
|
||||
cyan: {
|
||||
50: '#EDFDFD',
|
||||
100: '#C4F1F9',
|
||||
200: '#9DECF9',
|
||||
300: '#76E4F7',
|
||||
400: '#0BC5EA',
|
||||
500: '#00B5D8',
|
||||
600: '#00A3C4',
|
||||
700: '#0987A0',
|
||||
800: '#086F83',
|
||||
900: '#065666',
|
||||
},
|
||||
|
||||
purple: {
|
||||
50: '#FAF5FF',
|
||||
100: '#E9D8FD',
|
||||
200: '#D6BCFA',
|
||||
300: '#B794F4',
|
||||
400: '#9F7AEA',
|
||||
500: '#805AD5',
|
||||
600: '#6B46C1',
|
||||
700: '#553C9A',
|
||||
800: '#44337A',
|
||||
900: '#322659',
|
||||
},
|
||||
|
||||
pink: {
|
||||
50: '#FFF5F7',
|
||||
100: '#FED7E2',
|
||||
200: '#FBB6CE',
|
||||
300: '#F687B3',
|
||||
400: '#ED64A6',
|
||||
500: '#D53F8C',
|
||||
600: '#B83280',
|
||||
700: '#97266D',
|
||||
800: '#702459',
|
||||
900: '#521B41',
|
||||
},
|
||||
};
|
||||
|
||||
export default colors;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { tableAnatomy } from '@chakra-ui/anatomy';
|
||||
import { createMultiStyleConfigHelpers } from '@chakra-ui/react';
|
||||
|
||||
const { definePartsStyle, defineMultiStyleConfig } =
|
||||
createMultiStyleConfigHelpers(tableAnatomy.keys);
|
||||
|
||||
const variantRounded = definePartsStyle((props) => {
|
||||
const { colorScheme: c, colorMode } = props;
|
||||
|
||||
return {
|
||||
th: {
|
||||
'&[data-is-numeric=true]': {
|
||||
textAlign: 'end',
|
||||
},
|
||||
},
|
||||
td: {
|
||||
'&[data-is-numeric=true]': {
|
||||
textAlign: 'end',
|
||||
},
|
||||
},
|
||||
caption: {
|
||||
color: colorMode === 'light' ? `${c}.600` : `${c}.100`,
|
||||
},
|
||||
tbody: {
|
||||
tr: {
|
||||
'&:nth-of-type(odd)': {
|
||||
'th, td': {
|
||||
borderColor: colorMode === 'light' ? `${c}.100` : `${c}.700`,
|
||||
},
|
||||
td: {
|
||||
background: colorMode === 'light' ? `${c}.100` : `${c}.700`,
|
||||
},
|
||||
},
|
||||
'&:nth-of-type(even)': {
|
||||
'th, td': {
|
||||
borderColor: colorMode === 'light' ? `${c}.300` : `${c}.600`,
|
||||
},
|
||||
td: {
|
||||
background: colorMode === 'light' ? `${c}.300` : `${c}.600`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tfoot: {
|
||||
tr: {
|
||||
'&:last-of-type': {
|
||||
th: { borderBottomWidth: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const tableTheme = defineMultiStyleConfig({
|
||||
variants: { variantRounded },
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { extendTheme } from '@chakra-ui/react';
|
||||
import colors from './colors';
|
||||
import { tableTheme } from './foundations/Table';
|
||||
|
||||
const theme = extendTheme({
|
||||
styles: {
|
||||
global: {
|
||||
'html, body': {
|
||||
bg: 'myBackground.100',
|
||||
fontSize: 'md',
|
||||
fontWeight: 400,
|
||||
height: '100%',
|
||||
},
|
||||
a: {
|
||||
color: 'myPrimary.100',
|
||||
padding: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
borders: {
|
||||
base: '1px solid #E3E3E3',
|
||||
},
|
||||
colors: {
|
||||
// myText: {
|
||||
// 50: '#E6E6E7 ',
|
||||
// 100: '#313132',
|
||||
// 200: '#626263',
|
||||
// 300: '#929495',
|
||||
// 400: '#C3C5C6',
|
||||
// 500: '#F4F6F8',
|
||||
// 600: '#FEFEFE',
|
||||
// 700: '#FFFFFF',
|
||||
// 800: '#FFFFFF',
|
||||
// 900: '#FFFFFF',
|
||||
// },
|
||||
// myBackground: {
|
||||
// // 从黑色到白色的灰色调色阶
|
||||
// 50: '#23252D',
|
||||
// 100: '#2b2e39',
|
||||
// 200: '#323540',
|
||||
// 300: '#3a3f4a',
|
||||
// 400: '#424950',
|
||||
// 500: '#4A535B',
|
||||
// 600: '#53606B',
|
||||
// 700: '#5D6D7E',
|
||||
// 800: '#687A90',
|
||||
// 900: '#7488A1',
|
||||
// },
|
||||
myPrimary: {
|
||||
50: '#E6C4A8',
|
||||
100: '#ec7210',
|
||||
200: '#f09259',
|
||||
300: '#f4b2a1',
|
||||
400: '#F7CFC9',
|
||||
500: '#F9EDE2',
|
||||
600: '#FCFBF5',
|
||||
700: '#FEFDFB',
|
||||
800: '#FFFFFF',
|
||||
900: '#FFFFFF',
|
||||
},
|
||||
// // 定义边框和分隔线的颜色
|
||||
// myBorder: {
|
||||
// 50: '#353A43',
|
||||
// 100: '#4A5568',
|
||||
// 200: '#3f454e',
|
||||
// 300: '#282c32',
|
||||
// 400: '#1D2026',
|
||||
// 500: '#131519',
|
||||
// 600: '#0B0D0F',
|
||||
// 700: '#050608',
|
||||
// 800: '#020304',
|
||||
// 900: '#000000',
|
||||
// },
|
||||
},
|
||||
components: {
|
||||
Table: tableTheme,
|
||||
},
|
||||
});
|
||||
|
||||
theme.colors = {
|
||||
...theme.colors,
|
||||
...colors,
|
||||
};
|
||||
|
||||
export default theme;
|
||||
@@ -0,0 +1 @@
|
||||
declare module 'node-cron';
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["dom", "es2022"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"outDir": ".erb/dll"
|
||||
},
|
||||
"exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
|
||||
}
|
||||