Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into develop
This commit is contained in:
@@ -63,7 +63,7 @@ const Login: React.FC = () => {
|
||||
// 添加typeId参数
|
||||
const loginParams = {
|
||||
...values,
|
||||
typeId: activeTab as number,
|
||||
typeId: 1,
|
||||
};
|
||||
|
||||
const response =
|
||||
|
||||
8
Moncter/.gitignore
vendored
Normal file
8
Moncter/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/runtime
|
||||
/.idea
|
||||
/.vscode
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
/tests/tmp
|
||||
/tests/.phpunit.result.cache
|
||||
21
Moncter/LICENSE
Normal file
21
Moncter/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
70
Moncter/README.md
Normal file
70
Moncter/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
|
||||
<h1>webman</h1>
|
||||
|
||||
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
|
||||
|
||||
|
||||
<h1>学习</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style="float:left;padding-bottom:30px;">
|
||||
|
||||
<h1>赞助商</h1>
|
||||
|
||||
<h4>特别赞助</h4>
|
||||
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
|
||||
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
|
||||
</a>
|
||||
|
||||
<h4>铂金赞助</h4>
|
||||
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
|
||||
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="float:left;padding-bottom:30px;clear:both">
|
||||
|
||||
<h1>请作者喝咖啡</h1>
|
||||
|
||||
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
|
||||
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
|
||||
<br>
|
||||
<b>如果您觉得webman对您有所帮助,欢迎捐赠。</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="clear: both">
|
||||
<h1>LICENSE</h1>
|
||||
The webman is open-sourced software licensed under the MIT.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
24
Moncter/app/controller/IndexController.php
Normal file
24
Moncter/app/controller/IndexController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return "我是数据中心,有何贵干?";
|
||||
}
|
||||
|
||||
public function view(Request $request)
|
||||
{
|
||||
return view('index/view', ['name' => 'webman']);
|
||||
}
|
||||
|
||||
public function json(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
}
|
||||
16
Moncter/app/controller/UserController.php
Normal file
16
Moncter/app/controller/UserController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function hello(Request $request)
|
||||
{
|
||||
$default_name = 'webman';
|
||||
// 从get请求里获得name参数,如果没有传递name参数则返回$default_name
|
||||
$name = $request->get('name', $default_name);
|
||||
// 向浏览器返回字符串
|
||||
return response('hello ' . $name);
|
||||
}
|
||||
}
|
||||
4
Moncter/app/functions.php
Normal file
4
Moncter/app/functions.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
42
Moncter/app/middleware/StaticFile.php
Normal file
42
Moncter/app/middleware/StaticFile.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* Class StaticFile
|
||||
* @package app\middleware
|
||||
*/
|
||||
class StaticFile implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
// Access to files beginning with. Is prohibited
|
||||
if (strpos($request->path(), '/.') !== false) {
|
||||
return response('<h1>403 forbidden</h1>', 403);
|
||||
}
|
||||
/** @var Response $response */
|
||||
$response = $handler($request);
|
||||
// Add cross domain HTTP header
|
||||
/*$response->withHeaders([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);*/
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
29
Moncter/app/model/Test.php
Normal file
29
Moncter/app/model/Test.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class Test extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'test';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
}
|
||||
37
Moncter/app/model/User.php
Normal file
37
Moncter/app/model/User.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// app/model/User.php
|
||||
namespace app\model;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use MongoDB\Laravel\Relations\HasMany; // 若需关联查询(可选)
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
// 对应 MongoDB 集合名(默认复数,可自定义)
|
||||
protected $collection = 'users';
|
||||
|
||||
// 主键(MongoDB 默认 _id,无需修改,自动转为字符串)
|
||||
protected $primaryKey = '_id';
|
||||
|
||||
// 主键类型(官方推荐显式声明)
|
||||
protected $keyType = 'string';
|
||||
|
||||
// 允许批量赋值的字段(白名单)
|
||||
protected $fillable = ['name', 'age', 'email', 'avatar'];
|
||||
|
||||
// 自动转换字段类型(ObjectId 转字符串、日期转 Carbon)
|
||||
protected $casts = [
|
||||
'_id' => 'string',
|
||||
'age' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'tags' => 'array', // 支持数组类型(MongoDB 原生支持数组)
|
||||
];
|
||||
|
||||
// 自动维护时间戳(created_at/updated_at,默认启用)
|
||||
// 若不需要可关闭:public $timestamps = false;
|
||||
|
||||
// 自定义时间戳字段名(可选)
|
||||
// const CREATED_AT = 'create_time';
|
||||
// const UPDATED_AT = 'update_time';
|
||||
}
|
||||
10
Moncter/app/process/Http.php
Normal file
10
Moncter/app/process/Http.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use Webman\App;
|
||||
|
||||
class Http extends App
|
||||
{
|
||||
|
||||
}
|
||||
305
Moncter/app/process/Monitor.php
Normal file
305
Moncter/app/process/Monitor.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Class FileMonitor
|
||||
* @package process
|
||||
*/
|
||||
class Monitor
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $paths = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $extensions = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $loadedFiles = [];
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected int $ppid = 0;
|
||||
|
||||
/**
|
||||
* Pause monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function pause(): void
|
||||
{
|
||||
file_put_contents(static::lockFile(), time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function resume(): void
|
||||
{
|
||||
clearstatcache();
|
||||
if (is_file(static::lockFile())) {
|
||||
unlink(static::lockFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether monitor is paused
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPaused(): bool
|
||||
{
|
||||
clearstatcache();
|
||||
return file_exists(static::lockFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock file
|
||||
* @return string
|
||||
*/
|
||||
protected static function lockFile(): string
|
||||
{
|
||||
return runtime_path('monitor.lock');
|
||||
}
|
||||
|
||||
/**
|
||||
* FileMonitor constructor.
|
||||
* @param $monitorDir
|
||||
* @param $monitorExtensions
|
||||
* @param array $options
|
||||
*/
|
||||
public function __construct($monitorDir, $monitorExtensions, array $options = [])
|
||||
{
|
||||
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
|
||||
static::resume();
|
||||
$this->paths = (array)$monitorDir;
|
||||
$this->extensions = $monitorExtensions;
|
||||
foreach (get_included_files() as $index => $file) {
|
||||
$this->loadedFiles[$file] = $index;
|
||||
if (strpos($file, 'webman-framework/src/support/App.php')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
$disableFunctions = explode(',', ini_get('disable_functions'));
|
||||
if (in_array('exec', $disableFunctions, true)) {
|
||||
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
|
||||
} else {
|
||||
if ($options['enable_file_monitor'] ?? true) {
|
||||
Timer::add(1, function () {
|
||||
$this->checkAllFilesChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
|
||||
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
|
||||
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $monitorDir
|
||||
* @return bool
|
||||
*/
|
||||
public function checkFilesChange($monitorDir): bool
|
||||
{
|
||||
static $lastMtime, $tooManyFilesCheck;
|
||||
if (!$lastMtime) {
|
||||
$lastMtime = time();
|
||||
}
|
||||
clearstatcache();
|
||||
if (!is_dir($monitorDir)) {
|
||||
if (!is_file($monitorDir)) {
|
||||
return false;
|
||||
}
|
||||
$iterator = [new SplFileInfo($monitorDir)];
|
||||
} else {
|
||||
// recursive traversal directory
|
||||
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
|
||||
$iterator = new RecursiveIteratorIterator($dirIterator);
|
||||
}
|
||||
$count = 0;
|
||||
foreach ($iterator as $file) {
|
||||
$count ++;
|
||||
/** var SplFileInfo $file */
|
||||
if (is_dir($file->getRealPath())) {
|
||||
continue;
|
||||
}
|
||||
// check mtime
|
||||
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
|
||||
$lastMtime = $file->getMTime();
|
||||
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
|
||||
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
|
||||
continue;
|
||||
}
|
||||
$var = 0;
|
||||
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
|
||||
if ($var) {
|
||||
continue;
|
||||
}
|
||||
// send SIGUSR1 signal to master process for reload
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
if ($masterPid = $this->getMasterPid()) {
|
||||
echo $file . " updated and reload\n";
|
||||
posix_kill($masterPid, SIGUSR1);
|
||||
} else {
|
||||
echo "Master process has gone away and can not reload\n";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
echo $file . " updated and reload\n";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!$tooManyFilesCheck && $count > 1000) {
|
||||
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
|
||||
$tooManyFilesCheck = 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getMasterPid(): int
|
||||
{
|
||||
if ($this->ppid === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
|
||||
echo "Master process has gone away\n";
|
||||
return $this->ppid = 0;
|
||||
}
|
||||
if (PHP_OS_FAMILY !== 'Linux') {
|
||||
return $this->ppid;
|
||||
}
|
||||
$cmdline = "/proc/$this->ppid/cmdline";
|
||||
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
|
||||
// Process not exist
|
||||
$this->ppid = 0;
|
||||
}
|
||||
return $this->ppid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function checkAllFilesChange(): bool
|
||||
{
|
||||
if (static::isPaused()) {
|
||||
return false;
|
||||
}
|
||||
foreach ($this->paths as $path) {
|
||||
if ($this->checkFilesChange($path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $memoryLimit
|
||||
* @return void
|
||||
*/
|
||||
public function checkMemory($memoryLimit): void
|
||||
{
|
||||
if (static::isPaused() || $memoryLimit <= 0) {
|
||||
return;
|
||||
}
|
||||
$masterPid = $this->getMasterPid();
|
||||
if ($masterPid <= 0) {
|
||||
echo "Master process has gone away\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
|
||||
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
|
||||
return;
|
||||
}
|
||||
foreach (explode(' ', $children) as $pid) {
|
||||
$pid = (int)$pid;
|
||||
$statusFile = "/proc/$pid/status";
|
||||
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
|
||||
continue;
|
||||
}
|
||||
$mem = 0;
|
||||
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
|
||||
$mem = $match[1];
|
||||
}
|
||||
$mem = (int)($mem / 1024);
|
||||
if ($mem >= $memoryLimit) {
|
||||
posix_kill($pid, SIGINT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory limit
|
||||
* @param $memoryLimit
|
||||
* @return int
|
||||
*/
|
||||
protected function getMemoryLimit($memoryLimit): int
|
||||
{
|
||||
if ($memoryLimit === 0) {
|
||||
return 0;
|
||||
}
|
||||
$usePhpIni = false;
|
||||
if (!$memoryLimit) {
|
||||
$memoryLimit = ini_get('memory_limit');
|
||||
$usePhpIni = true;
|
||||
}
|
||||
|
||||
if ($memoryLimit == -1) {
|
||||
return 0;
|
||||
}
|
||||
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
|
||||
$memoryLimit = (int)$memoryLimit;
|
||||
if ($unit === 'g') {
|
||||
$memoryLimit = 1024 * $memoryLimit;
|
||||
} else if ($unit === 'k') {
|
||||
$memoryLimit = ($memoryLimit / 1024);
|
||||
} else if ($unit === 'm') {
|
||||
$memoryLimit = (int)($memoryLimit);
|
||||
} else if ($unit === 't') {
|
||||
$memoryLimit = (1024 * 1024 * $memoryLimit);
|
||||
} else {
|
||||
$memoryLimit = ($memoryLimit / (1024 * 1024));
|
||||
}
|
||||
if ($memoryLimit < 50) {
|
||||
$memoryLimit = 50;
|
||||
}
|
||||
if ($usePhpIni) {
|
||||
$memoryLimit = (0.8 * $memoryLimit);
|
||||
}
|
||||
return (int)$memoryLimit;
|
||||
}
|
||||
|
||||
}
|
||||
14
Moncter/app/view/index/view.html
Normal file
14
Moncter/app/view/index/view.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="/favicon.ico"/>
|
||||
<title>webman</title>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
hello <?=htmlspecialchars($name)?>
|
||||
</body>
|
||||
</html>
|
||||
56
Moncter/composer.json
Normal file
56
Moncter/composer.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "workerman/webman",
|
||||
"type": "project",
|
||||
"keywords": [
|
||||
"high performance",
|
||||
"http service"
|
||||
],
|
||||
"homepage": "https://www.workerman.net",
|
||||
"license": "MIT",
|
||||
"description": "High performance HTTP Service Framework.",
|
||||
"authors": [
|
||||
{
|
||||
"name": "walkor",
|
||||
"email": "walkor@workerman.net",
|
||||
"homepage": "https://www.workerman.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"email": "walkor@workerman.net",
|
||||
"issues": "https://github.com/walkor/webman/issues",
|
||||
"forum": "https://wenda.workerman.net/",
|
||||
"wiki": "https://workerman.net/doc/webman",
|
||||
"source": "https://github.com/walkor/webman"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"workerman/webman-framework": "^2.1",
|
||||
"monolog/monolog": "^2.0",
|
||||
"mongodb/laravel-mongodb": "^4.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"": "./",
|
||||
"app\\": "./app",
|
||||
"App\\": "./app",
|
||||
"app\\View\\Components\\": "./app/view/components"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-package-install": [
|
||||
"support\\Plugin::install"
|
||||
],
|
||||
"post-package-update": [
|
||||
"support\\Plugin::install"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"support\\Plugin::uninstall"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
2455
Moncter/composer.lock
generated
Normal file
2455
Moncter/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Moncter/config/app.php
Normal file
26
Moncter/config/app.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Request;
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
'error_reporting' => E_ALL,
|
||||
'default_timezone' => 'Asia/Shanghai',
|
||||
'request_class' => Request::class,
|
||||
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
|
||||
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
];
|
||||
21
Moncter/config/autoload.php
Normal file
21
Moncter/config/autoload.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'files' => [
|
||||
base_path() . '/app/functions.php',
|
||||
base_path() . '/support/Request.php',
|
||||
base_path() . '/support/Response.php',
|
||||
]
|
||||
];
|
||||
17
Moncter/config/bootstrap.php
Normal file
17
Moncter/config/bootstrap.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
support\bootstrap\Session::class,
|
||||
];
|
||||
15
Moncter/config/container.php
Normal file
15
Moncter/config/container.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return new Webman\Container;
|
||||
27
Moncter/config/database.php
Normal file
27
Moncter/config/database.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
return [
|
||||
// 默认数据库连接(可选改为 mongodb)
|
||||
'default' => 'mysql', // 若需全局用 MongoDB,改为 'mongodb'
|
||||
|
||||
'connections' => [
|
||||
// ... 其他连接(如 mysql)保持不变
|
||||
|
||||
// MongoDB 官方连接配置
|
||||
'mongodb' => [
|
||||
'driver' => 'mongodb',
|
||||
'dsn' => 'mongodb://127.0.0.1:27017', // 集群可写:mongodb://node1:27017,node2:27017
|
||||
'database' => 'Moncter', // 目标数据库名
|
||||
'username' => 'Moncter', // 无认证则省略
|
||||
'password' => '123456', // 无认证则省略
|
||||
'options' => [
|
||||
'replicaSet' => '', // 副本集名称(无则留空)
|
||||
'ssl' => false, // 是否启用 SSL
|
||||
'connectTimeoutMS' => 3000, // 连接超时
|
||||
'socketTimeoutMS' => 5000, // 读写超时
|
||||
// 认证相关(若 MongoDB 启用认证)
|
||||
'authSource' => 'admin', // 认证数据库(默认 admin)
|
||||
'authMechanism' => 'SCRAM-SHA-256', // 认证机制(默认推荐)
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
15
Moncter/config/dependence.php
Normal file
15
Moncter/config/dependence.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [];
|
||||
17
Moncter/config/exception.php
Normal file
17
Moncter/config/exception.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'' => support\exception\Handler::class,
|
||||
];
|
||||
32
Moncter/config/log.php
Normal file
32
Moncter/config/log.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/webman.log',
|
||||
7, //$maxFiles
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
15
Moncter/config/middleware.php
Normal file
15
Moncter/config/middleware.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [];
|
||||
62
Moncter/config/process.php
Normal file
62
Moncter/config/process.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
use app\process\Http;
|
||||
|
||||
global $argv;
|
||||
|
||||
return [
|
||||
'webman' => [
|
||||
'handler' => Http::class,
|
||||
'listen' => 'http://0.0.0.0:8787',
|
||||
'count' => cpu_count() * 4,
|
||||
'user' => '',
|
||||
'group' => '',
|
||||
'reusePort' => false,
|
||||
'eventLoop' => '',
|
||||
'context' => [],
|
||||
'constructor' => [
|
||||
'requestClass' => Request::class,
|
||||
'logger' => Log::channel('default'),
|
||||
'appPath' => app_path(),
|
||||
'publicPath' => public_path()
|
||||
]
|
||||
],
|
||||
// File update detection and automatic reload
|
||||
'monitor' => [
|
||||
'handler' => app\process\Monitor::class,
|
||||
'reloadable' => false,
|
||||
'constructor' => [
|
||||
// Monitor these directories
|
||||
'monitorDir' => array_merge([
|
||||
app_path(),
|
||||
config_path(),
|
||||
base_path() . '/process',
|
||||
base_path() . '/support',
|
||||
base_path() . '/resource',
|
||||
base_path() . '/.env',
|
||||
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
|
||||
// Files with these suffixes will be monitored
|
||||
'monitorExtensions' => [
|
||||
'php', 'html', 'htm', 'env'
|
||||
],
|
||||
'options' => [
|
||||
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
|
||||
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
21
Moncter/config/route.php
Normal file
21
Moncter/config/route.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
23
Moncter/config/server.php
Normal file
23
Moncter/config/server.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'event_loop' => '',
|
||||
'stop_timeout' => 2,
|
||||
'pid_file' => runtime_path() . '/webman.pid',
|
||||
'status_file' => runtime_path() . '/webman.status',
|
||||
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
||||
'log_file' => runtime_path() . '/logs/workerman.log',
|
||||
'max_package_size' => 10 * 1024 * 1024
|
||||
];
|
||||
65
Moncter/config/session.php
Normal file
65
Moncter/config/session.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Session\FileSessionHandler;
|
||||
use Webman\Session\RedisSessionHandler;
|
||||
use Webman\Session\RedisClusterSessionHandler;
|
||||
|
||||
return [
|
||||
|
||||
'type' => 'file', // or redis or redis_cluster
|
||||
|
||||
'handler' => FileSessionHandler::class,
|
||||
|
||||
'config' => [
|
||||
'file' => [
|
||||
'save_path' => runtime_path() . '/sessions',
|
||||
],
|
||||
'redis' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
'auth' => '',
|
||||
'timeout' => 2,
|
||||
'database' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
],
|
||||
'redis_cluster' => [
|
||||
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
|
||||
'timeout' => 2,
|
||||
'auth' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
]
|
||||
],
|
||||
|
||||
'session_name' => 'PHPSID',
|
||||
|
||||
'auto_update_timestamp' => false,
|
||||
|
||||
'lifetime' => 7*24*60*60,
|
||||
|
||||
'cookie_lifetime' => 365*24*60*60,
|
||||
|
||||
'cookie_path' => '/',
|
||||
|
||||
'domain' => '',
|
||||
|
||||
'http_only' => true,
|
||||
|
||||
'secure' => false,
|
||||
|
||||
'same_site' => '',
|
||||
|
||||
'gc_probability' => [1, 1000],
|
||||
|
||||
];
|
||||
23
Moncter/config/static.php
Normal file
23
Moncter/config/static.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static file settings
|
||||
*/
|
||||
return [
|
||||
'enable' => true,
|
||||
'middleware' => [ // Static file Middleware
|
||||
//app\middleware\StaticFile::class,
|
||||
],
|
||||
];
|
||||
25
Moncter/config/translation.php
Normal file
25
Moncter/config/translation.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Multilingual configuration
|
||||
*/
|
||||
return [
|
||||
// Default language
|
||||
'locale' => 'zh_CN',
|
||||
// Fallback language
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
// Folder where language files are stored
|
||||
'path' => base_path() . '/resource/translations',
|
||||
];
|
||||
22
Moncter/config/view.php
Normal file
22
Moncter/config/view.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\view\Raw;
|
||||
use support\view\Twig;
|
||||
use support\view\Blade;
|
||||
use support\view\ThinkPHP;
|
||||
|
||||
return [
|
||||
'handler' => Raw::class
|
||||
];
|
||||
46
Moncter/go.sh
Executable file
46
Moncter/go.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail # 严格模式:报错立即退出、禁止未定义变量、管道错误触发退出
|
||||
|
||||
# ================= 配置项(可根据实际情况修改)=================
|
||||
# PHP 脚本路径(相对路径/绝对路径均可,推荐绝对路径更稳定)
|
||||
PHP_SCRIPT="start.php"
|
||||
# PHP 解释器路径(默认自动查找,若提示 php 未找到,手动指定如 /usr/bin/php)
|
||||
PHP_BIN=$(which php || echo "/usr/bin/php")
|
||||
# ==============================================================
|
||||
|
||||
# 1. 检查 PHP 解释器是否存在且可执行
|
||||
if [ ! -x "$PHP_BIN" ]; then
|
||||
echo -e "\033[31m错误:未找到可执行的 PHP 解释器!\033[0m"
|
||||
echo " 解决方案:"
|
||||
echo " 1. 安装 PHP:sudo apt install php-cli(Ubuntu/Debian)或 sudo dnf install php-cli(CentOS/RHEL)"
|
||||
echo " 2. 若已安装,手动修改脚本中的 PHP_BIN 为实际路径(通过 which php 查询)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 检查 PHP 脚本是否存在
|
||||
if [ ! -f "$PHP_SCRIPT" ]; then
|
||||
echo -e "\033[31m错误:未找到脚本文件 $PHP_SCRIPT!\033[0m"
|
||||
echo " 请确保脚本与 $PHP_SCRIPT 在同一目录,或修改脚本中的 PHP_SCRIPT 为绝对路径"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 给 PHP 脚本添加执行权限(自动修复权限问题)
|
||||
if [ ! -x "$PHP_SCRIPT" ]; then
|
||||
echo -e "\033[33m警告:$PHP_SCRIPT 缺少执行权限,正在自动添加...\033[0m"
|
||||
chmod u+x "$PHP_SCRIPT" || {
|
||||
echo -e "\033[31m错误:添加执行权限失败,请用 sudo 运行脚本!\033[0m"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# 4. 执行核心命令(带日志输出优化)
|
||||
echo -e "\033[32m=== 开始执行:$PHP_BIN $PHP_SCRIPT start ===\033[0m"
|
||||
$PHP_BIN "$PHP_SCRIPT" start
|
||||
|
||||
# 5. 执行结果判断
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "\033[32m=== 执行成功!===\033[0m"
|
||||
else
|
||||
echo -e "\033[31m=== 执行失败!请查看上方错误信息 ===\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
BIN
Moncter/public/favicon.ico
Normal file
BIN
Moncter/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
5
Moncter/start.php
Executable file
5
Moncter/start.php
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
support\App::run();
|
||||
24
Moncter/support/Request.php
Normal file
24
Moncter/support/Request.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace support;
|
||||
|
||||
/**
|
||||
* Class Request
|
||||
* @package support
|
||||
*/
|
||||
class Request extends \Webman\Http\Request
|
||||
{
|
||||
|
||||
}
|
||||
24
Moncter/support/Response.php
Normal file
24
Moncter/support/Response.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace support;
|
||||
|
||||
/**
|
||||
* Class Response
|
||||
* @package support
|
||||
*/
|
||||
class Response extends \Webman\Http\Response
|
||||
{
|
||||
|
||||
}
|
||||
139
Moncter/support/bootstrap.php
Normal file
139
Moncter/support/bootstrap.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use support\Log;
|
||||
use Webman\Bootstrap;
|
||||
use Webman\Config;
|
||||
use Webman\Middleware;
|
||||
use Webman\Route;
|
||||
use Webman\Util;
|
||||
use Workerman\Events\Select;
|
||||
use Workerman\Worker;
|
||||
|
||||
$worker = $worker ?? null;
|
||||
|
||||
if (empty(Worker::$eventLoopClass)) {
|
||||
Worker::$eventLoopClass = Select::class;
|
||||
}
|
||||
|
||||
set_error_handler(function ($level, $message, $file = '', $line = 0) {
|
||||
if (error_reporting() & $level) {
|
||||
throw new ErrorException($message, 0, $level, $file, $line);
|
||||
}
|
||||
});
|
||||
|
||||
if ($worker) {
|
||||
register_shutdown_function(function ($startTime) {
|
||||
if (time() - $startTime <= 0.1) {
|
||||
sleep(1);
|
||||
}
|
||||
}, time());
|
||||
}
|
||||
|
||||
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
|
||||
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
|
||||
Dotenv::createUnsafeMutable(base_path(false))->load();
|
||||
} else {
|
||||
Dotenv::createMutable(base_path(false))->load();
|
||||
}
|
||||
}
|
||||
|
||||
Config::clear();
|
||||
support\App::loadAllConfig(['route']);
|
||||
if ($timezone = config('app.default_timezone')) {
|
||||
date_default_timezone_set($timezone);
|
||||
}
|
||||
|
||||
foreach (config('autoload.files', []) as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['autoload']['files'] ?? [] as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
}
|
||||
foreach ($projects['autoload']['files'] ?? [] as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
Middleware::load(config('middleware', []));
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project) || $name === 'static') {
|
||||
continue;
|
||||
}
|
||||
Middleware::load($project['middleware'] ?? []);
|
||||
}
|
||||
Middleware::load($projects['middleware'] ?? [], $firm);
|
||||
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
|
||||
Middleware::load(['__static__' => $staticMiddlewares], $firm);
|
||||
}
|
||||
}
|
||||
Middleware::load(['__static__' => config('static.middleware', [])]);
|
||||
|
||||
foreach (config('bootstrap', []) as $className) {
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['bootstrap'] ?? [] as $className) {
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
}
|
||||
foreach ($projects['bootstrap'] ?? [] as $className) {
|
||||
/** @var string $className */
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
}
|
||||
|
||||
$directory = base_path() . '/plugin';
|
||||
$paths = [config_path()];
|
||||
foreach (Util::scanDir($directory) as $path) {
|
||||
if (is_dir($path = "$path/config")) {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
Route::load($paths);
|
||||
|
||||
3
Moncter/windows.bat
Normal file
3
Moncter/windows.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
CHCP 65001
|
||||
php windows.php
|
||||
pause
|
||||
136
Moncter/windows.php
Normal file
136
Moncter/windows.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Start file for windows
|
||||
*/
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use support\App;
|
||||
use Workerman\Worker;
|
||||
|
||||
ini_set('display_errors', 'on');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
|
||||
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
|
||||
Dotenv::createUnsafeImmutable(base_path())->load();
|
||||
} else {
|
||||
Dotenv::createMutable(base_path())->load();
|
||||
}
|
||||
}
|
||||
|
||||
App::loadAllConfig(['route']);
|
||||
|
||||
$errorReporting = config('app.error_reporting');
|
||||
if (isset($errorReporting)) {
|
||||
error_reporting($errorReporting);
|
||||
}
|
||||
|
||||
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
|
||||
$paths = [
|
||||
$runtimeProcessPath,
|
||||
runtime_path('logs'),
|
||||
runtime_path('views')
|
||||
];
|
||||
foreach ($paths as $path) {
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
$processFiles = [];
|
||||
if (config('server.listen')) {
|
||||
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
|
||||
}
|
||||
foreach (config('process', []) as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
|
||||
}
|
||||
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['process'] ?? [] as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
|
||||
}
|
||||
}
|
||||
foreach ($projects['process'] ?? [] as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
|
||||
}
|
||||
}
|
||||
|
||||
function write_process_file($runtimeProcessPath, $processName, $firm): string
|
||||
{
|
||||
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
|
||||
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
|
||||
$fileContent = <<<EOF
|
||||
<?php
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
use Workerman\Worker;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Webman\Config;
|
||||
use support\App;
|
||||
|
||||
ini_set('display_errors', 'on');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (is_callable('opcache_reset')) {
|
||||
opcache_reset();
|
||||
}
|
||||
|
||||
if (!\$appConfigFile = config_path('app.php')) {
|
||||
throw new RuntimeException('Config file not found: app.php');
|
||||
}
|
||||
\$appConfig = require \$appConfigFile;
|
||||
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
|
||||
date_default_timezone_set(\$timezone);
|
||||
}
|
||||
|
||||
App::loadAllConfig(['route']);
|
||||
|
||||
worker_start('$processParam', $configParam);
|
||||
|
||||
if (DIRECTORY_SEPARATOR != "/") {
|
||||
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
|
||||
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
|
||||
}
|
||||
|
||||
Worker::runAll();
|
||||
|
||||
EOF;
|
||||
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
|
||||
file_put_contents($processFile, $fileContent);
|
||||
return $processFile;
|
||||
}
|
||||
|
||||
if ($monitorConfig = config('process.monitor.constructor')) {
|
||||
$monitorHandler = config('process.monitor.handler');
|
||||
$monitor = new $monitorHandler(...array_values($monitorConfig));
|
||||
}
|
||||
|
||||
function popen_processes($processFiles)
|
||||
{
|
||||
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
|
||||
$descriptorspec = [STDIN, STDOUT, STDOUT];
|
||||
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
|
||||
if (!$resource) {
|
||||
exit("Can not execute $cmd\r\n");
|
||||
}
|
||||
return $resource;
|
||||
}
|
||||
|
||||
$resource = popen_processes($processFiles);
|
||||
echo "\r\n";
|
||||
while (1) {
|
||||
sleep(1);
|
||||
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
|
||||
$status = proc_get_status($resource);
|
||||
$pid = $status['pid'];
|
||||
shell_exec("taskkill /F /T /PID $pid");
|
||||
proc_close($resource);
|
||||
$resource = popen_processes($processFiles);
|
||||
}
|
||||
}
|
||||
348
Moncter/技术方案.md
Normal file
348
Moncter/技术方案.md
Normal file
@@ -0,0 +1,348 @@
|
||||
## 用户标签引擎技术方案(以身份证为主键)
|
||||
|
||||
### 一、目标与范围
|
||||
- 构建可扩展的用户标签引擎,统一以身份证为主键的人(person)进行画像与筛选。
|
||||
- 支持多数据源接入(交易、行为、社群、外呼等),并聚合多个手机号、多个微信号到同一人。
|
||||
- 提供规则驱动的人群筛选、客群快照和线索分发能力,服务销售精细化运营。
|
||||
|
||||
### 0. 需求整合清单(共识)
|
||||
- 数据接入:多数据源/多数据库连接,按“数据源-表/接口”粒度定义 Job;增量水位、批量与重试。
|
||||
- 标识治理:支持弱标识(手机号等)建“临时人”,获取强标识(身份证/unionid/客户号)后合并;全链路幂等与审计。
|
||||
- 标签体系:分通道层与人层;`tag_dict` 定义口径/类型/窗口/聚合/版本;标签写入包含 window/source/version。
|
||||
- 聚合计算:通道→人层遵循 `aggregation`(sum/max/avg/any/best_of 等),支持实时触发与离线批处理。
|
||||
- 规则与人群:DSL 配置、试算/执行、审计;Redis 维护 cohort/位图,支持快照与导出。
|
||||
- 回灌与重算:规则或口径变更可对存量回评估;任务状态、错误与执行明细可观测、可重试。
|
||||
- 安全合规:身份证只存哈希(加盐),最小化数据使用,接口鉴权与导出留痕。
|
||||
|
||||
### 二、总体架构
|
||||
- 核心理念:人层(person)是唯一真相;通道层(channel)承载具体手机号/微信号等标识。
|
||||
- 组件分层:
|
||||
- 数据接入层:标准化事件/明细,写入通道层标签。
|
||||
- 聚合计算层:将通道层指标按口径聚合到人层标签。
|
||||
- 规则/人群层:基于人层标签做筛选、快照、导出与分发。
|
||||
- 存储与缓存:MySQL(字典/事实/审计)+ Redis(cohort 人群集与位图)。
|
||||
|
||||
### 二点五、运行逻辑图(分层架构,数据流视角)
|
||||
```mermaid
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 数据源层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│交易系统│ │APP行为│ │客服系统│ │CRM │
|
||||
└─────┘ └─────┘ └─────┘ └─────┘
|
||||
│ │ │ │
|
||||
└───────┴─────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 接入层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ Job调度器 增量水位 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ 标准化 校验 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 身份层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ IdentifierService │
|
||||
│ 手机号→person_id │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ 临时人建表 强标识合并 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 标签层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ channel_tags │
|
||||
│ 通道标签存储 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ Aggregator │
|
||||
│ 聚合计算 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ person_tags │
|
||||
│ 人层标签存储 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 规则层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ RuleEngine │
|
||||
│ DSL执行 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ Redis Cohort │
|
||||
│ 人群集合 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 应用层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌─────┐ ┌─────┐ ┌─────┐
|
||||
│人群查询│ │快照导出│ │分发推送│
|
||||
└─────┘ └─────┘ └─────┘
|
||||
|
||||
```
|
||||
|
||||
### 二点六、运行逻辑图(时序视角)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant 业务系统 as 业务系统<br/>(交易/APP/客服)
|
||||
participant 接入服务 as 数据接入服务
|
||||
participant 身份服务 as 身份解析服务
|
||||
participant 通道标签 as channel_tags<br/>(存储)
|
||||
participant 聚合服务 as 聚合计算服务
|
||||
participant 人层标签 as person_tags<br/>(存储)
|
||||
participant 规则引擎 as 规则引擎
|
||||
participant 人群缓存 as Redis Cohort
|
||||
|
||||
业务系统->>接入服务: 1. 推送事件/批量数据
|
||||
接入服务->>接入服务: 2. 标准化、校验、去重
|
||||
接入服务->>身份服务: 3. 解析标识<br/>(手机号/微信→person_id)
|
||||
身份服务-->>接入服务: 返回person_id<br/>(不存在则建临时人)
|
||||
接入服务->>通道标签: 4. 写入通道标签<br/>(幂等、window/source/version)
|
||||
|
||||
通道标签->>聚合服务: 5. 触发聚合事件<br/>(实时/批量)
|
||||
聚合服务->>人层标签: 6. 按口径聚合<br/>(sum/max/avg/any)
|
||||
|
||||
人层标签->>规则引擎: 7. 标签变更触发<br/>(受影响person_id)
|
||||
规则引擎->>规则引擎: 8. 执行DSL规则
|
||||
规则引擎->>人群缓存: 9. 更新cohort<br/>(SADD/SINTER)
|
||||
|
||||
人群缓存-->>业务系统: 10. 人群查询/导出
|
||||
```
|
||||
|
||||
### 二点七、运行逻辑图(简化版,核心路径)
|
||||
```mermaid
|
||||
graph TB
|
||||
Start([数据源<br/>交易/行为/客服/CRM]) --> Ingest[数据接入<br/>Job调度 + 标准化]
|
||||
Ingest --> Identity[身份解析<br/>手机号→person_id]
|
||||
Identity --> Channel[通道标签<br/>channel_tags]
|
||||
Channel --> Aggregate[聚合计算<br/>通道→人层]
|
||||
Aggregate --> Person[人层标签<br/>person_tags]
|
||||
Person --> Rule[规则引擎<br/>DSL筛选]
|
||||
Rule --> Cohort[人群集合<br/>Redis Cohort]
|
||||
Cohort --> Export([应用输出<br/>查询/快照/分发])
|
||||
|
||||
Ingest -.->|状态| Audit1[(ingest_state)]
|
||||
Ingest -.->|错误| Audit2[(ingest_errors)]
|
||||
Rule -.->|执行记录| Audit3[(rule_executions)]
|
||||
|
||||
style Start fill:#e1f5ff
|
||||
style Export fill:#e1f5ff
|
||||
style Ingest fill:#fff4e1
|
||||
style Identity fill:#e8f5e9
|
||||
style Channel fill:#f3e5f5
|
||||
style Aggregate fill:#f3e5f5
|
||||
style Person fill:#f3e5f5
|
||||
style Rule fill:#fff9c4
|
||||
style Cohort fill:#fff9c4
|
||||
```
|
||||
|
||||
### 三、数据模型
|
||||
```sql
|
||||
-- 人:身份证(脱敏哈希)为主键
|
||||
CREATE TABLE person (
|
||||
person_id CHAR(32) PRIMARY KEY, -- md5(uppercase(id_card_no_without_spaces))
|
||||
id_card_hash CHAR(32) UNIQUE,
|
||||
name VARCHAR(64) NULL,
|
||||
gender TINYINT NULL,
|
||||
birthday DATE NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 标识绑定:一个人可有多个手机号/微信/外部ID
|
||||
CREATE TABLE person_identifier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
person_id CHAR(32),
|
||||
id_type ENUM('phone','wechat','external','email') NOT NULL,
|
||||
id_value VARCHAR(128) NOT NULL,
|
||||
is_primary TINYINT DEFAULT 0,
|
||||
verified TINYINT DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
UNIQUE KEY uk_type_value (id_type, id_value),
|
||||
KEY idx_person (person_id)
|
||||
);
|
||||
|
||||
-- 标签字典
|
||||
CREATE TABLE tag_dict (
|
||||
tag_code VARCHAR(128) PRIMARY KEY, -- 例:person.trade.arpu_90d
|
||||
name VARCHAR(128),
|
||||
category VARCHAR(64),
|
||||
level ENUM('person','channel') NOT NULL,
|
||||
type ENUM('int','bool','enum','set','string','float') NOT NULL,
|
||||
enum_values JSON NULL,
|
||||
unit VARCHAR(16) NULL,
|
||||
aggregation ENUM('sum','max','min','avg','any','best_of') NULL, -- 通道→人层口径
|
||||
description TEXT,
|
||||
version INT DEFAULT 1,
|
||||
status ENUM('draft','active','deprecated') DEFAULT 'active',
|
||||
owner VARCHAR(64),
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 人层标签(销售筛选用)
|
||||
CREATE TABLE person_tags (
|
||||
person_id CHAR(32),
|
||||
tag_code VARCHAR(128),
|
||||
tag_value VARCHAR(256),
|
||||
confidence TINYINT DEFAULT 100,
|
||||
source VARCHAR(64),
|
||||
window VARCHAR(32),
|
||||
version INT,
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (person_id, tag_code),
|
||||
KEY idx_tag (tag_code, tag_value)
|
||||
);
|
||||
|
||||
-- 通道层标签(手机号/微信号维度)
|
||||
CREATE TABLE channel_tags (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
id_type ENUM('phone','wechat','external') NOT NULL,
|
||||
id_value VARCHAR(128) NOT NULL,
|
||||
tag_code VARCHAR(128),
|
||||
tag_value VARCHAR(256),
|
||||
source VARCHAR(64),
|
||||
window VARCHAR(32),
|
||||
updated_at DATETIME,
|
||||
UNIQUE KEY uk_dim (id_type, id_value, tag_code),
|
||||
KEY idx_tag (tag_code, tag_value)
|
||||
);
|
||||
|
||||
-- 规则配置与执行审计
|
||||
CREATE TABLE tag_rules (
|
||||
rule_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(128),
|
||||
dsl JSON,
|
||||
status ENUM('draft','active','paused') DEFAULT 'active',
|
||||
schedule VARCHAR(64) NULL,
|
||||
output_tag VARCHAR(128) NULL,
|
||||
owner VARCHAR(64),
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE rule_executions (
|
||||
exec_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
rule_id BIGINT,
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
affected_users INT,
|
||||
status ENUM('success','failed','partial'),
|
||||
message TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### 四、标签规范
|
||||
- 分类:
|
||||
- 基础画像、行为活跃、交易能力、风险合规、社交关系、生命周期、设备画像、地域属性。
|
||||
- 命名:`{level}.{category}.{name}_{window}`
|
||||
- 人层:`person.trade.arpu_90d`,`person.behavior.active_days_30d`
|
||||
- 通道层:`channel.wechat.group_join_30d`,`channel.phone.outbound_connect_7d`
|
||||
- 口径:在 `tag_dict` 中声明 `type/enum/range/aggregation/window/update_freq/version`。
|
||||
- 聚合:
|
||||
- 风险类:any(任一通道命中→人层命中)。
|
||||
- 活跃/价值:max/sum/avg 视业务定义。
|
||||
- 可达性:best_of(如选近30日响应率最高的手机号)。
|
||||
|
||||
### 五、规则与人群(DSL)
|
||||
```json
|
||||
{
|
||||
"name": "高潜付费人群",
|
||||
"logic": "AND",
|
||||
"conditions": [
|
||||
{"tag": "person.trade.arpu_90d", "op": "gte", "value": 500},
|
||||
{"tag": "person.behavior.active_days_30d", "op": "gte", "value": 10},
|
||||
{"tag": "person.risk.blacklist", "op": "eq", "value": false}
|
||||
],
|
||||
"window": "rolling_90d",
|
||||
"ttl": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
### 六、计算与刷新策略
|
||||
- 批处理(T+1/T+0小时):生成稳定画像(交易统计、生命周期等)。
|
||||
- 准实时(秒级/分级):事件驱动更新通道层标签,触发对应人层增量聚合。
|
||||
- 回评估:字典/规则变更后对存量人群重算,并写入审计。
|
||||
|
||||
### 七、系统设计(ThinkPHP 5.1)
|
||||
- 目录结构(`application/tag`):
|
||||
- controller:`TagDictController`、`RuleController`、`SegmentController`、`IdentifierController`
|
||||
- model:`TagDict`、`Person`、`PersonIdentifier`、`PersonTags`、`ChannelTags`、`TagRules`、`RuleExecutions`
|
||||
- service:
|
||||
- `IdentifierService`(标识绑定/查找/合并)
|
||||
- `ChannelTagService`(通道标签写入)
|
||||
- `PersonTagService`(人层聚合/重算)
|
||||
- `TagQueryService`(条件解析→Redis/DB组合查询)
|
||||
- `RuleEngineService`(DSL 校验/执行/审计)
|
||||
- `CohortCacheService`(cohort 维护、集合/位图运算)
|
||||
- command:`ExecuteRule`、`RecomputeTags`、`CohortSnapshot`
|
||||
|
||||
- 路由(建议,受 `jwt` 保护):
|
||||
```php
|
||||
Route::group('v1/tag', function () {
|
||||
Route::get('dict', 'app\\tag\\controller\\TagDictController@index');
|
||||
Route::post('dict', 'app\\tag\\controller\\TagDictController@create');
|
||||
Route::post('identifier/bind', 'app\\tag\\controller\\IdentifierController@bind');
|
||||
Route::post('rule/execute/:id', 'app\\tag\\controller\\RuleController@execute');
|
||||
Route::post('segment/query', 'app\\tag\\controller\\SegmentController@query');
|
||||
Route::post('segment/snapshot', 'app\\tag\\controller\\SegmentController@snapshot');
|
||||
})->middleware(['jwt']);
|
||||
```
|
||||
|
||||
### 八、查询与筛选
|
||||
- 人层为主:`person_tags` 组合条件查询,Redis 保存常用 cohort:
|
||||
- Redis 示例:`SINTER cohort:person.trade.arpu_90d:gt500 cohort:person.active_days_30d:gte10 SDIFF cohort:person.risk.blacklist:eq1`
|
||||
- MySQL 组合查询示例:
|
||||
```sql
|
||||
SELECT DISTINCT t1.person_id
|
||||
FROM person_tags t1
|
||||
JOIN person_tags t2 ON t2.person_id=t1.person_id
|
||||
LEFT JOIN person_tags t3 ON t3.person_id=t1.person_id AND t3.tag_code='person.risk.blacklist'
|
||||
WHERE t1.tag_code='person.trade.arpu_90d' AND CAST(t1.tag_value AS DECIMAL)>=500
|
||||
AND t2.tag_code='person.behavior.active_days_30d' AND CAST(t2.tag_value AS SIGNED)>=10
|
||||
AND (t3.tag_value IS NULL OR t3.tag_value='0')
|
||||
LIMIT 50 OFFSET 0;
|
||||
```
|
||||
|
||||
### 九、关键流程
|
||||
1) 事件接入:收到 `id_type + id_value`(如手机号)→ 查 `person_identifier` → 得到 `person_id`。
|
||||
2) 通道标签更新:写 `channel_tags`,并发布“聚合任务”。
|
||||
3) 人层聚合:按 `tag_dict.aggregation` 规则,更新 `person_tags`。
|
||||
4) 规则评估:对受影响的 `person_id` 运行启用中的规则,更新 cohort/输出标签。
|
||||
5) 人群产出:支持分页查询、生成快照、导出或推送 CRM/外呼系统。
|
||||
|
||||
### 十、缓存与索引
|
||||
- Redis:
|
||||
- 集合/位图存 cohort,Key 规范:`cohort:{tag_code}:{op}{value}` 或区间桶。
|
||||
- TTL:默认 24h,可按规则 `ttl` 覆盖。
|
||||
- MySQL:
|
||||
- `person_tags(tag_code, tag_value)`、`channel_tags(tag_code, tag_value)` 倒排索引。
|
||||
- 审计表按时间分区或冷热分离。
|
||||
|
||||
### 十一、合规与安全
|
||||
- 身份证只存哈希(不可逆),不落明文;导出脱敏。
|
||||
- 最小权限访问,接口留痕审计(规则执行、导出、查看)。
|
||||
- 口径透明:标签保留来源、窗口、置信度、版本。
|
||||
|
||||
### 十二、里程碑(落地计划)
|
||||
- M1:建表与服务骨架;接入交易/行为两类数据;产出10个核心标签。
|
||||
- M2:规则试算与快照;Redis cohort;首批销售客群模板(高价值流失预警)。
|
||||
- M3:通道“最佳触达”策略;CRM/外呼对接;质量监控与看板。
|
||||
|
||||
|
||||
25
Moncter/数据库列表.md
Normal file
25
Moncter/数据库列表.md
Normal file
@@ -0,0 +1,25 @@
|
||||
KR
|
||||
KR_KR
|
||||
KR_LinkedIn
|
||||
KR_存客宝
|
||||
KR_存客宝_四表重构KR_KR版
|
||||
KR_国外
|
||||
KR_户口
|
||||
KR_京东
|
||||
KR_酒店
|
||||
KR_卡套私域
|
||||
KR_快递
|
||||
KR_魔兽世界
|
||||
KR_企业
|
||||
KR_企业名录
|
||||
KR_人才库
|
||||
KR_商城
|
||||
KR_手机
|
||||
KR_顺丰
|
||||
KR_淘宝
|
||||
KR_腾讯
|
||||
KR_投资
|
||||
KR_微博
|
||||
KR_香港在大陆投资企业名录
|
||||
KR_销售额3000万元-5000万元企业名录
|
||||
KR_游戏
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
import { uploadFile } from "@/api/common";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
|
||||
onAudioUploaded: (audioData: {
|
||||
url: string;
|
||||
name: string;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
maxDuration?: number; // 最大录音时长(秒)
|
||||
@@ -206,6 +210,7 @@ const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||
// 调用回调函数,传递音频URL和时长(毫秒)
|
||||
onAudioUploaded({
|
||||
url: filePath,
|
||||
name: audioFile.name,
|
||||
durationMs: recordingTime * 1000, // 将秒转换为毫秒
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useRef } from "react";
|
||||
import { message } from "antd";
|
||||
|
||||
interface SimpleFileUploadProps {
|
||||
onFileUploaded?: (filePath: string) => void;
|
||||
onFileUploaded?: (filePath: { name: string; url: string }) => void;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
type?: number; // 1: 图片, 2: 视频, 3: 音频, 4: 文件
|
||||
slot?: React.ReactNode;
|
||||
@@ -51,7 +51,10 @@ const SimpleFileUpload: React.FC<SimpleFileUploadProps> = ({
|
||||
|
||||
try {
|
||||
const fileUrl = await uploadFile(file);
|
||||
onFileUploaded?.(fileUrl);
|
||||
onFileUploaded?.({
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
});
|
||||
message.success("文件上传成功");
|
||||
} catch (error: any) {
|
||||
console.error("文件上传失败:", error);
|
||||
|
||||
75
Touchkebao/src/pages/404/index.module.scss
Normal file
75
Touchkebao/src/pages/404/index.module.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 20px 0 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
59
Touchkebao/src/pages/404/index.tsx
Normal file
59
Touchkebao/src/pages/404/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "antd-mobile";
|
||||
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const NotFound: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className={styles["not-found-container"]}>
|
||||
<div className={styles["not-found-content"]}>
|
||||
{/* 404 图标 */}
|
||||
<div className={styles["error-code"]}>404</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
<h1 className={styles["error-title"]}>页面未找到</h1>
|
||||
<p className={styles["error-description"]}>
|
||||
抱歉,您访问的页面不存在或已被删除
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={styles["action-buttons"]}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGoHome}
|
||||
className={styles["action-btn"]}
|
||||
>
|
||||
<HomeOutlined />
|
||||
<span>返回首页</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="default"
|
||||
size="large"
|
||||
onClick={handleGoBack}
|
||||
className={styles["action-btn"]}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
<span>返回上页</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -89,6 +89,7 @@ const Login: React.FC = () => {
|
||||
const loginParams = {
|
||||
...values,
|
||||
verifySessionId: verify.verifySessionId,
|
||||
typeId: 1,
|
||||
};
|
||||
|
||||
const response =
|
||||
|
||||
@@ -34,7 +34,6 @@ const { sendCommand } = useWebSocketStore.getState();
|
||||
|
||||
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||||
const EnterModule = useWeChatStore(state => state.EnterModule);
|
||||
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
|
||||
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
|
||||
@@ -254,20 +253,43 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
FILE: 5,
|
||||
};
|
||||
const handleFileUploaded = (
|
||||
filePath: string | { url: string; durationMs: number },
|
||||
filePath: { url: string; name: string; durationMs?: number },
|
||||
fileType: number,
|
||||
) => {
|
||||
console.log("handleFileUploaded: ", fileType, filePath);
|
||||
|
||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
let msgType = 1;
|
||||
let content: any = "";
|
||||
if ([FileType.TEXT].includes(fileType)) {
|
||||
msgType = getMsgTypeByFileFormat(filePath as string);
|
||||
msgType = getMsgTypeByFileFormat(filePath.url);
|
||||
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||
msgType = 3;
|
||||
content = filePath.url;
|
||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||
msgType = 34;
|
||||
content = JSON.stringify({
|
||||
url: filePath.url,
|
||||
durationMs: filePath.durationMs,
|
||||
});
|
||||
} else if ([FileType.FILE].includes(fileType)) {
|
||||
msgType = 49;
|
||||
msgType = getMsgTypeByFileFormat(filePath.url);
|
||||
if (msgType === 3) {
|
||||
content = filePath.url;
|
||||
}
|
||||
if (msgType === 43) {
|
||||
content = filePath.url;
|
||||
}
|
||||
|
||||
if (msgType === 49) {
|
||||
content = JSON.stringify({
|
||||
type: "file",
|
||||
title: filePath.name,
|
||||
url: filePath.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
const messageId = +Date.now();
|
||||
|
||||
const params = {
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
@@ -275,10 +297,37 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||
msgSubType: 0,
|
||||
msgType,
|
||||
content: [FileType.AUDIO].includes(fileType)
|
||||
? JSON.stringify(filePath)
|
||||
: filePath,
|
||||
content: content,
|
||||
seq: messageId,
|
||||
};
|
||||
|
||||
// 构造本地消息对象
|
||||
const localMessage: ChatRecord = {
|
||||
id: messageId, // 使用时间戳作为临时ID
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||
tenantId: 0,
|
||||
accountId: 0,
|
||||
synergyAccountId: 0,
|
||||
content: params.content,
|
||||
msgType: msgType,
|
||||
msgSubType: 0,
|
||||
msgSvrId: "",
|
||||
isSend: true, // 标记为发送中
|
||||
createTime: new Date().toISOString(),
|
||||
isDeleted: false,
|
||||
deleteTime: "",
|
||||
sendStatus: 1,
|
||||
wechatTime: Date.now(),
|
||||
origin: 0,
|
||||
msgId: 0,
|
||||
recalled: false,
|
||||
seq: messageId,
|
||||
};
|
||||
// 先插入本地数据
|
||||
addMessage(localMessage);
|
||||
|
||||
sendCommand("CmdSendMessage", params);
|
||||
};
|
||||
|
||||
@@ -349,10 +398,10 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
<div className={styles.leftTool}>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.FILE)
|
||||
onFileUploaded={fileInfo =>
|
||||
handleFileUploaded(fileInfo, FileType.FILE)
|
||||
}
|
||||
maxSize={1}
|
||||
maxSize={10}
|
||||
type={4}
|
||||
slot={
|
||||
<Button
|
||||
@@ -363,10 +412,10 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
}
|
||||
/>
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.IMAGE)
|
||||
onFileUploaded={fileInfo =>
|
||||
handleFileUploaded(fileInfo, FileType.IMAGE)
|
||||
}
|
||||
maxSize={1}
|
||||
maxSize={10}
|
||||
type={1}
|
||||
slot={
|
||||
<Button
|
||||
@@ -379,7 +428,14 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
|
||||
<AudioRecorder
|
||||
onAudioUploaded={audioData =>
|
||||
handleFileUploaded(audioData, FileType.AUDIO)
|
||||
handleFileUploaded(
|
||||
{
|
||||
name: audioData.name,
|
||||
url: audioData.url,
|
||||
durationMs: audioData.durationMs,
|
||||
},
|
||||
FileType.AUDIO,
|
||||
)
|
||||
}
|
||||
className={styles.toolbarButton}
|
||||
/>
|
||||
@@ -462,87 +518,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
</>
|
||||
)}
|
||||
</Footer>
|
||||
|
||||
{/* 素材选择模态框 */}
|
||||
<Modal
|
||||
title="选择素材"
|
||||
open={showMaterialModal}
|
||||
onCancel={() => setShowMaterialModal(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
onClick={() => setShowMaterialModal(false)}
|
||||
>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
<div style={{ display: "flex", height: "400px" }}>
|
||||
{/* 左侧素材分类 */}
|
||||
<div
|
||||
style={{
|
||||
width: "200px",
|
||||
background: "#f5f5f5",
|
||||
borderRight: "1px solid #e8e8e8",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
|
||||
<h4 style={{ margin: 0, color: "#262626" }}>公共素材</h4>
|
||||
</div>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
background: "#e6f7ff",
|
||||
borderLeft: "3px solid #1890ff",
|
||||
color: "#1890ff",
|
||||
}}
|
||||
>
|
||||
暗黑4
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
针对老客户的...
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
D2辅助
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
ROS反馈演示...
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
一键宏产品素...
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
|
||||
<h4 style={{ margin: 0, color: "#262626" }}>部门素材</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容区域 */}
|
||||
<div style={{ flex: 1, padding: "16px" }}>
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "300px",
|
||||
color: "#8c8c8c",
|
||||
}}
|
||||
>
|
||||
请选择左侧素材分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
、
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
const messageData = JSON.parse(trimmedContent);
|
||||
|
||||
// 处理文章类型消息
|
||||
if (messageData.type === "link" && messageData.title && messageData.url) {
|
||||
if (messageData.type === "link") {
|
||||
const { title, desc, thumbPath, url } = messageData;
|
||||
|
||||
return (
|
||||
@@ -68,7 +68,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
}
|
||||
|
||||
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
|
||||
if (messageData.type === "miniprogram" && messageData.contentXml) {
|
||||
if (messageData.type === "miniprogram") {
|
||||
try {
|
||||
const parsedData = parseWeappMsgStr(trimmedContent);
|
||||
|
||||
@@ -144,52 +144,21 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 验证传统JSON格式的小程序数据结构
|
||||
if (
|
||||
messageData &&
|
||||
typeof messageData === "object" &&
|
||||
(messageData.title || messageData.appName)
|
||||
) {
|
||||
return (
|
||||
<div className={styles.miniProgramMessage}>
|
||||
<div className={styles.miniProgramCard}>
|
||||
{messageData.thumb && (
|
||||
<img
|
||||
src={messageData.thumb}
|
||||
alt="小程序缩略图"
|
||||
className={styles.miniProgramThumb}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.miniProgramInfo}>
|
||||
<div className={styles.miniProgramTitle}>
|
||||
{messageData.title || "小程序消息"}
|
||||
</div>
|
||||
{messageData.appName && (
|
||||
<div className={styles.miniProgramApp}>
|
||||
{messageData.appName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
//处理文档类型消息
|
||||
|
||||
if (messageData.type === "file") {
|
||||
const { url, title } = messageData;
|
||||
// 增强的文件消息处理
|
||||
const isFileUrl =
|
||||
content.startsWith("http") ||
|
||||
content.startsWith("https") ||
|
||||
content.startsWith("file://") ||
|
||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
|
||||
url.startsWith("http") ||
|
||||
url.startsWith("https") ||
|
||||
url.startsWith("file://") ||
|
||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(url);
|
||||
|
||||
if (isFileUrl) {
|
||||
// 尝试从URL中提取文件名
|
||||
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
|
||||
const fileName =
|
||||
title || url.split("/").pop()?.split("?")[0] || "文件";
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择图标
|
||||
@@ -235,7 +204,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
className={styles.fileAction}
|
||||
onClick={() => {
|
||||
try {
|
||||
window.open(content, "_blank");
|
||||
window.open(messageData.url, "_blank");
|
||||
} catch (e) {
|
||||
console.error("文件打开失败:", e);
|
||||
}
|
||||
@@ -248,6 +217,43 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证传统JSON格式的小程序数据结构
|
||||
// if (
|
||||
// messageData &&
|
||||
// typeof messageData === "object" &&
|
||||
// (messageData.title || messageData.appName)
|
||||
// ) {
|
||||
// return (
|
||||
// <div className={styles.miniProgramMessage}>
|
||||
// <div className={styles.miniProgramCard}>
|
||||
// {messageData.thumb && (
|
||||
// <img
|
||||
// src={messageData.thumb}
|
||||
// alt="小程序缩略图"
|
||||
// className={styles.miniProgramThumb}
|
||||
// onError={e => {
|
||||
// const target = e.target as HTMLImageElement;
|
||||
// target.style.display = "none";
|
||||
// }}
|
||||
// />
|
||||
// )}
|
||||
// <div className={styles.miniProgramInfo}>
|
||||
// <div className={styles.miniProgramTitle}>
|
||||
// {messageData.title || "小程序消息"}
|
||||
// </div>
|
||||
// {messageData.appName && (
|
||||
// <div className={styles.miniProgramApp}>
|
||||
// {messageData.appName}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
return renderErrorMessage("[小程序/文件消息]");
|
||||
} catch (e) {
|
||||
|
||||
@@ -39,6 +39,7 @@ const TransmitModal: React.FC = () => {
|
||||
|
||||
// 从 Zustand store 获取更新方法
|
||||
const openTransmitModal = useContactStore(state => state.openTransmitModal);
|
||||
|
||||
const setTransmitModal = useContactStore(state => state.setTransmitModal);
|
||||
const updateSelectedChatRecords = useWeChatStore(
|
||||
state => state.updateSelectedChatRecords,
|
||||
@@ -142,11 +143,11 @@ const TransmitModal: React.FC = () => {
|
||||
<Modal
|
||||
title="转发消息"
|
||||
open={openTransmitModal}
|
||||
onCancel={() => updateTransmitModal(false)}
|
||||
onCancel={() => setTransmitModal(false)}
|
||||
width={"60%"}
|
||||
className={styles.transmitModal}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => updateTransmitModal(false)}>
|
||||
<Button key="cancel" onClick={() => setTransmitModal(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
|
||||
@@ -57,7 +57,6 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
// 如果content是直接的视频链接(已预览过或下载好的视频)
|
||||
if (isDirectVideoLink(content)) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
@@ -76,7 +75,6 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +107,6 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
@@ -128,13 +125,11 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
@@ -168,7 +163,6 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from "@/api/request2";
|
||||
export const getWechatAccountInfo = (params: { id?: string }) => {
|
||||
return request("/api/wechataccount", params, "GET");
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
.addFriendModal {
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.searchInputWrapper {
|
||||
.ant-input {
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tipText {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.greetingWrapper {
|
||||
margin-bottom: 20px;
|
||||
.ant-input {
|
||||
resize: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.label {
|
||||
width: 60px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-start;
|
||||
|
||||
.addButton {
|
||||
background-color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
color: #fff;
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: #73d13d;
|
||||
border-color: #73d13d;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
border-color: #d9d9d9;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Input, Button, message, Select } from "antd";
|
||||
import { SearchOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
interface AddFriendsProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddFriends: React.FC<AddFriendsProps> = ({ visible, onCancel }) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [greeting, setGreeting] = useState("我是老坑爹-解放双手,释放时间");
|
||||
const [remark, setRemark] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { sendCommand } = useWebSocketStore();
|
||||
const currentCustomer = useCustomerStore(state => state.currentCustomer);
|
||||
|
||||
// 获取标签列表(从 currentCustomer.labels 字符串数组)
|
||||
const tags = currentCustomer?.labels || [];
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
setSearchValue("");
|
||||
setGreeting("我是老坑爹-解放双手,释放时间");
|
||||
setRemark("");
|
||||
setSelectedTag(undefined);
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
handleReset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 判断是否为手机号(11位数字)
|
||||
const isPhoneNumber = (value: string): boolean => {
|
||||
return /^1[3-9]\d{9}$/.test(value.trim());
|
||||
};
|
||||
|
||||
// 处理添加好友
|
||||
const handleAddFriend = async () => {
|
||||
if (!searchValue.trim()) {
|
||||
message.warning("请输入微信号或手机号");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentCustomer?.id) {
|
||||
message.error("请先选择客服账号");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const trimmedValue = searchValue.trim();
|
||||
const isPhone = isPhoneNumber(trimmedValue);
|
||||
|
||||
// 发送添加好友命令
|
||||
sendCommand("CmdSendFriendRequest", {
|
||||
WechatAccountId: currentCustomer.id,
|
||||
TargetWechatId: isPhone ? "" : trimmedValue,
|
||||
Phone: isPhone ? trimmedValue : "",
|
||||
Message: greeting.trim() || "我是老坑爹-解放双手,释放时间",
|
||||
Remark: remark.trim() || "",
|
||||
Labels: selectedTag || "",
|
||||
});
|
||||
|
||||
message.success("好友请求已发送");
|
||||
handleCancel();
|
||||
} catch (error) {
|
||||
console.error("添加好友失败:", error);
|
||||
message.error("添加好友失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
className={styles.addFriendModal}
|
||||
width={480}
|
||||
closable={false}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
{/* 搜索输入框 */}
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
placeholder="请输入微信号/手机号"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onPressEnter={handleAddFriend}
|
||||
disabled={loading}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div className={styles.tipText}>你需要发送验证申请,等待对方通过</div>
|
||||
|
||||
{/* 验证消息文本区域 */}
|
||||
<div className={styles.greetingWrapper}>
|
||||
<Input.TextArea
|
||||
value={greeting}
|
||||
onChange={e => setGreeting(e.target.value)}
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 备注输入框 */}
|
||||
<div className={styles.formRow}>
|
||||
<span className={styles.label}>备注:</span>
|
||||
<Input
|
||||
value={remark}
|
||||
onChange={e => setRemark(e.target.value)}
|
||||
placeholder="请输入备注"
|
||||
disabled={loading}
|
||||
className={styles.inputField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签选择器 */}
|
||||
<div className={styles.formRow}>
|
||||
<span className={styles.label}>标签:</span>
|
||||
<Select
|
||||
value={selectedTag}
|
||||
onChange={setSelectedTag}
|
||||
placeholder="请选择标签"
|
||||
disabled={loading}
|
||||
className={styles.selectField}
|
||||
suffixIcon={<DownOutlined />}
|
||||
allowClear
|
||||
>
|
||||
{tags.map(tag => (
|
||||
<Select.Option key={tag} value={tag}>
|
||||
{tag}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleAddFriend}
|
||||
loading={loading}
|
||||
className={styles.addButton}
|
||||
>
|
||||
加为好友
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className={styles.cancelButton}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFriends;
|
||||
@@ -0,0 +1,153 @@
|
||||
.popChatRoomModal {
|
||||
.ant-modal-content {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(500px - 110px);
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.contentBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.contactList,
|
||||
.selectedList {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
padding: 12px 16px;
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.listContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.contactItem,
|
||||
.selectedItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #d9d9d9;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.selectedItem {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.contactInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contactName {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conRemark {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.removeIcon {
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.popChatRoomModal {
|
||||
.ant-modal-content {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
max-height: calc(600px - 110px);
|
||||
}
|
||||
}
|
||||
|
||||
.contentBody {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contactList,
|
||||
.selectedList {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Button,
|
||||
Avatar,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
Pagination,
|
||||
} from "antd";
|
||||
import { SearchOutlined, CloseOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import styles from "./index.module.scss";
|
||||
import { ContactManager } from "@/utils/dbAction";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { Contact } from "@/utils/db";
|
||||
|
||||
interface PopChatRoomProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PopChatRoom: React.FC<PopChatRoomProps> = ({ visible, onCancel }) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [allContacts, setAllContacts] = useState<Contact[]>([]);
|
||||
const [selectedContacts, setSelectedContacts] = useState<Contact[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [showNameModal, setShowNameModal] = useState(false);
|
||||
const [chatroomName, setChatroomName] = useState("");
|
||||
const pageSize = 10;
|
||||
const { sendCommand } = useWebSocketStore();
|
||||
const currentUserId = useUserStore(state => state.user?.id) || 0;
|
||||
const currentCustomer = useCustomerStore(state => state.currentCustomer);
|
||||
|
||||
// 加载联系人数据(只加载好友,不包含群聊)
|
||||
const loadContacts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allContactsData =
|
||||
await ContactManager.getUserContacts(currentUserId);
|
||||
// 过滤出好友类型,排除群聊
|
||||
const friendsOnly = (allContactsData as Contact[]).filter(
|
||||
contact => contact.type === "friend",
|
||||
);
|
||||
setAllContacts(friendsOnly);
|
||||
} catch (err) {
|
||||
console.error("加载联系人数据失败:", err);
|
||||
message.error("加载联系人数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchValue("");
|
||||
setSelectedContacts([]);
|
||||
setPage(1);
|
||||
loadContacts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
// 过滤联系人 - 支持名称和拼音搜索
|
||||
const filteredContacts = useMemo(() => {
|
||||
if (!searchValue.trim()) return allContacts;
|
||||
|
||||
const keyword = searchValue.toLowerCase();
|
||||
return allContacts.filter(contact => {
|
||||
const name = (contact.nickname || "").toLowerCase();
|
||||
const remark = (contact.conRemark || "").toLowerCase();
|
||||
const quanPin = (contact as any).quanPin?.toLowerCase?.() || "";
|
||||
const pinyin = (contact as any).pinyin?.toLowerCase?.() || "";
|
||||
return (
|
||||
name.includes(keyword) ||
|
||||
remark.includes(keyword) ||
|
||||
quanPin.includes(keyword) ||
|
||||
pinyin.includes(keyword)
|
||||
);
|
||||
});
|
||||
}, [allContacts, searchValue]);
|
||||
|
||||
const paginatedContacts = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return filteredContacts.slice(start, end);
|
||||
}, [filteredContacts, page]);
|
||||
|
||||
// 处理联系人选择
|
||||
const handleContactSelect = (contact: Contact) => {
|
||||
setSelectedContacts(prev => {
|
||||
if (isContactSelected(contact.id)) {
|
||||
return prev.filter(item => item.id !== contact.id);
|
||||
}
|
||||
return [...prev, contact];
|
||||
});
|
||||
};
|
||||
|
||||
// 移除已选择的联系人
|
||||
const handleRemoveSelected = (contactId: number) => {
|
||||
setSelectedContacts(prev =>
|
||||
prev.filter(contact => contact.id !== contactId),
|
||||
);
|
||||
};
|
||||
|
||||
// 检查联系人是否已选择
|
||||
const isContactSelected = (contactId: number) => {
|
||||
return selectedContacts.some(contact => contact.id === contactId);
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
setSearchValue("");
|
||||
setSelectedContacts([]);
|
||||
setPage(1);
|
||||
setChatroomName("");
|
||||
setShowNameModal(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 处理创建群聊 - 先显示输入群名称的弹窗
|
||||
const handleCreateGroup = () => {
|
||||
if (selectedContacts.length === 0) {
|
||||
message.warning("请至少选择一个联系人");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentCustomer?.id) {
|
||||
message.error("请先选择客服账号");
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示输入群名称的弹窗
|
||||
setShowNameModal(true);
|
||||
};
|
||||
|
||||
// 确认创建群聊
|
||||
const handleConfirmCreate = () => {
|
||||
if (!chatroomName.trim()) {
|
||||
message.warning("请输入群聊名称");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentCustomer?.id) {
|
||||
message.error("请先选择客服账号");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选中的好友ID列表
|
||||
const friendIds = selectedContacts.map(contact => contact.id);
|
||||
|
||||
try {
|
||||
// 发送创建群聊命令
|
||||
sendCommand("CmdChatroomCreate", {
|
||||
wechatAccountId: currentCustomer.id,
|
||||
chatroomName: chatroomName.trim(),
|
||||
wechatFriendIds: friendIds,
|
||||
});
|
||||
|
||||
message.success("群聊创建请求已发送");
|
||||
handleCancel();
|
||||
} catch (error) {
|
||||
console.error("创建群聊失败:", error);
|
||||
message.error("创建群聊失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
// 取消输入群名称
|
||||
const handleCancelNameInput = () => {
|
||||
setShowNameModal(false);
|
||||
setChatroomName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="发起群聊"
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
width="60%"
|
||||
className={styles.popChatRoomModal}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="create"
|
||||
type="primary"
|
||||
onClick={handleCreateGroup}
|
||||
disabled={selectedContacts.length === 0}
|
||||
loading={loading}
|
||||
>
|
||||
创建
|
||||
{selectedContacts.length > 0 && ` (${selectedContacts.length})`}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
placeholder="请输入昵称/微信号 搜索好友"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
disabled={loading}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentBody}>
|
||||
{/* 左侧联系人列表 */}
|
||||
<div className={styles.contactList}>
|
||||
<div className={styles.listHeader}>
|
||||
<span>联系人 ({filteredContacts.length})</span>
|
||||
</div>
|
||||
<div className={styles.listContent}>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<span>加载联系人中...</span>
|
||||
</div>
|
||||
) : filteredContacts.length > 0 ? (
|
||||
paginatedContacts.map(contact => (
|
||||
<div key={contact.id} className={styles.contactItem}>
|
||||
<Checkbox
|
||||
checked={isContactSelected(contact.id)}
|
||||
onChange={() => handleContactSelect(contact)}
|
||||
>
|
||||
<div className={styles.contactInfo}>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contact.avatar}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<div className={styles.contactName}>
|
||||
<div>{contact.nickname}</div>
|
||||
{contact.conRemark && (
|
||||
<div className={styles.conRemark}>
|
||||
{contact.conRemark}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
searchValue ? "未找到匹配的联系人" : "暂无联系人"
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{filteredContacts.length > 0 && (
|
||||
<div className={styles.paginationContainer}>
|
||||
<Pagination
|
||||
size="small"
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredContacts.length}
|
||||
onChange={p => setPage(p)}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧已选择列表 */}
|
||||
<div className={styles.selectedList}>
|
||||
<div className={styles.listHeader}>
|
||||
<span>已选联系人 ({selectedContacts.length})</span>
|
||||
</div>
|
||||
<div className={styles.listContent}>
|
||||
{selectedContacts.length > 0 ? (
|
||||
selectedContacts.map(contact => (
|
||||
<div key={contact.id} className={styles.selectedItem}>
|
||||
<div className={styles.contactInfo}>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contact.avatar}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<div className={styles.contactName}>
|
||||
<div>{contact.nickname}</div>
|
||||
{contact.conRemark && (
|
||||
<div className={styles.conRemark}>
|
||||
{contact.conRemark}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CloseOutlined
|
||||
className={styles.removeIcon}
|
||||
onClick={() => handleRemoveSelected(contact.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty
|
||||
description="请选择联系人"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 输入群名称的弹窗 */}
|
||||
<Modal
|
||||
title="提示"
|
||||
open={showNameModal}
|
||||
onCancel={handleCancelNameInput}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancelNameInput}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" onClick={handleConfirmCreate}>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>请输入群聊名称。</div>
|
||||
<Input
|
||||
placeholder="请输入群聊名称"
|
||||
value={chatroomName}
|
||||
onChange={e => setChatroomName(e.target.value)}
|
||||
onPressEnter={handleConfirmCreate}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopChatRoom;
|
||||
@@ -13,6 +13,8 @@
|
||||
margin-bottom: 16px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input, Skeleton } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Input, Skeleton, Button, Dropdown, MenuProps } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import WechatFriends from "./WechatFriends";
|
||||
import MessageList from "./MessageList/index";
|
||||
import FriendsCircle from "./FriendsCicle";
|
||||
import AddFriends from "./AddFriends";
|
||||
import PopChatRoom from "./PopChatRoom";
|
||||
import styles from "./SidebarMenu.module.scss";
|
||||
import { useContactStore } from "@/store/module/weChat/contacts";
|
||||
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||
@@ -28,6 +35,9 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
|
||||
const [activeTab, setActiveTab] = useState("chats");
|
||||
const [switchingTab, setSwitchingTab] = useState(false); // tab切换加载状态
|
||||
const [isAddFriendModalVisible, setIsAddFriendModalVisible] = useState(false);
|
||||
const [isCreateGroupModalVisible, setIsCreateGroupModalVisible] =
|
||||
useState(false);
|
||||
|
||||
// 监听 currentContact 变化,自动切换到聊天tab并选中会话
|
||||
useEffect(() => {
|
||||
@@ -68,6 +78,26 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
clearSearchKeyword();
|
||||
};
|
||||
|
||||
// 下拉菜单项
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "addFriend",
|
||||
label: "添加好友",
|
||||
icon: <UserAddOutlined />,
|
||||
onClick: () => {
|
||||
setIsAddFriendModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "createGroup",
|
||||
label: "发起群聊",
|
||||
icon: <TeamOutlined />,
|
||||
onClick: () => {
|
||||
setIsCreateGroupModalVisible(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 渲染骨架屏
|
||||
const renderSkeleton = () => (
|
||||
<div className={styles.skeletonContainer}>
|
||||
@@ -126,6 +156,15 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
onClear={handleClearSearch}
|
||||
allowClear
|
||||
/>
|
||||
{currentCustomer && (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />}></Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签页切换 */}
|
||||
@@ -181,6 +220,16 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
<div className={styles.sidebarMenu}>
|
||||
{renderHeader()}
|
||||
<div className={styles.contentContainer}>{renderContent()}</div>
|
||||
{/* 添加好友弹窗 */}
|
||||
<AddFriends
|
||||
visible={isAddFriendModalVisible}
|
||||
onCancel={() => setIsAddFriendModalVisible(false)}
|
||||
/>
|
||||
{/* 发起群聊弹窗 */}
|
||||
<PopChatRoom
|
||||
visible={isCreateGroupModalVisible}
|
||||
onCancel={() => setIsCreateGroupModalVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom";
|
||||
import PermissionRoute from "./permissionRoute";
|
||||
import NotFound from "@/pages/404";
|
||||
|
||||
// 动态导入所有 module 下的 ts/tsx 路由模块
|
||||
const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true });
|
||||
@@ -31,7 +32,15 @@ function wrapWithPermission(
|
||||
return route;
|
||||
}
|
||||
|
||||
const routes = allRoutes.map(wrapWithPermission);
|
||||
// 添加 404 路由(通配符路由,必须放在最后)
|
||||
const routes = [
|
||||
...allRoutes.map(wrapWithPermission),
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFound />,
|
||||
auth: false,
|
||||
},
|
||||
];
|
||||
|
||||
const AppRoutes = () => useRoutes(routes);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
* 微信聊天状态管理 Store
|
||||
* 使用 Zustand 管理微信聊天相关的状态和操作
|
||||
*/
|
||||
export const useWeChatStore = create<WeChatState>()(
|
||||
export const useDataCenterStore = create<WeChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
showChatRecordModel: false,
|
||||
@@ -153,7 +153,7 @@ export const useWeChatStore = create<WeChatState>()(
|
||||
contract: ContractData | weChatGroup,
|
||||
isExist?: boolean,
|
||||
) => {
|
||||
const state = useWeChatStore.getState();
|
||||
const state = useDataCenterStore.getState();
|
||||
// 切换联系人时清空当前消息,等待重新加载
|
||||
set({ currentMessages: [], openTransmitModal: false });
|
||||
|
||||
@@ -193,7 +193,7 @@ export const useWeChatStore = create<WeChatState>()(
|
||||
// ==================== 消息加载方法 ====================
|
||||
/** 加载聊天消息 */
|
||||
loadChatMessages: async (Init: boolean, To?: number) => {
|
||||
const state = useWeChatStore.getState();
|
||||
const state = useDataCenterStore.getState();
|
||||
const contact = state.currentContract;
|
||||
set({ messagesLoading: true });
|
||||
set({ isLoadingData: Init });
|
||||
@@ -258,7 +258,7 @@ export const useWeChatStore = create<WeChatState>()(
|
||||
keyword: string;
|
||||
Count?: number;
|
||||
}) => {
|
||||
const state = useWeChatStore.getState();
|
||||
const state = useDataCenterStore.getState();
|
||||
const contact = state.currentContract;
|
||||
set({ messagesLoading: true });
|
||||
|
||||
@@ -302,7 +302,7 @@ export const useWeChatStore = create<WeChatState>()(
|
||||
// ==================== 消息接收处理 ====================
|
||||
/** 接收新消息处理 */
|
||||
receivedMsg: async message => {
|
||||
const currentContract = useWeChatStore.getState().currentContract;
|
||||
const currentContract = useDataCenterStore.getState().currentContract;
|
||||
// 判断是群聊还是私聊
|
||||
const getMessageId =
|
||||
message?.wechatChatroomId || message.wechatFriendId;
|
||||
@@ -468,13 +468,16 @@ export const useWeChatStore = create<WeChatState>()(
|
||||
// ==================== 便捷选择器导出 ====================
|
||||
/** 获取当前联系人的 Hook */
|
||||
export const useCurrentContact = () =>
|
||||
useWeChatStore(state => state.currentContract);
|
||||
useDataCenterStore(state => state.currentContract);
|
||||
/** 获取当前消息列表的 Hook */
|
||||
export const useCurrentMessages = () =>
|
||||
useWeChatStore(state => state.currentMessages);
|
||||
useDataCenterStore(state => state.currentMessages);
|
||||
/** 获取消息加载状态的 Hook */
|
||||
export const useMessagesLoading = () =>
|
||||
useWeChatStore(state => state.messagesLoading);
|
||||
useDataCenterStore(state => state.messagesLoading);
|
||||
/** 获取复选框显示状态的 Hook */
|
||||
export const useShowCheckbox = () =>
|
||||
useWeChatStore(state => state.showCheckbox);
|
||||
useDataCenterStore(state => state.showCheckbox);
|
||||
|
||||
export const useUpdateTransmitModal = (open: boolean) =>
|
||||
useDataCenterStore(state => state.updateTransmitModal(open));
|
||||
|
||||
@@ -53,7 +53,7 @@ const messageHandlers: Record<string, MessageHandler> = {
|
||||
}
|
||||
},
|
||||
CmdSendMessageResult: message => {
|
||||
updateMessage(message.friendMessageId, {
|
||||
updateMessage(message.friendMessageId || message.chatroomMessageId, {
|
||||
sendStatus: 0,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user