Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into develop

This commit is contained in:
wong
2025-11-12 17:44:42 +08:00
60 changed files with 5513 additions and 282 deletions

View File

@@ -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
View File

@@ -0,0 +1,8 @@
/runtime
/.idea
/.vscode
/vendor
*.log
.env
/tests/tmp
/tests/.phpunit.result.cache

21
Moncter/LICENSE Normal file
View 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
View 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>

View 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']);
}
}

View 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);
}
}

View File

@@ -0,0 +1,4 @@
<?php
/**
* Here is your custom functions.
*/

View 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;
}
}

View 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;
}

View 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';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace app\process;
use Webman\App;
class Http extends App
{
}

View 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;
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

26
Moncter/config/app.php Normal file
View 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,
];

View 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',
]
];

View 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,
];

View 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;

View 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', // 认证机制(默认推荐)
],
],
],
];

View 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 [];

View 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
View 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],
],
]
],
],
];

View 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 [];

View 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
View 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
View 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
];

View 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
View 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,
],
];

View 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
View 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
View 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. 安装 PHPsudo apt install php-cliUbuntu/Debian或 sudo dnf install php-cliCentOS/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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

5
Moncter/start.php Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();

View 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
{
}

View 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
{
}

View 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
View File

@@ -0,0 +1,3 @@
CHCP 65001
php windows.php
pause

136
Moncter/windows.php Normal file
View 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
View 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字典/事实/审计)+ Rediscohort 人群集与位图)。
### 二点五、运行逻辑图(分层架构,数据流视角)
```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
- 集合/位图存 cohortKey 规范:`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/外呼对接;质量监控与看板。

View 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_游戏

View File

@@ -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, // 将秒转换为毫秒
});

View File

@@ -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);

View 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;
}
}

View 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;

View File

@@ -89,6 +89,7 @@ const Login: React.FC = () => {
const loginParams = {
...values,
verifySessionId: verify.verifySessionId,
typeId: 1,
};
const response =

View File

@@ -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>
</>
);
};

View File

@@ -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,109 +144,115 @@ 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}
//处理文档类型消息
if (messageData.type === "file") {
const { url, title } = messageData;
// 增强的文件消息处理
const isFileUrl =
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 =
title || url.split("/").pop()?.split("?")[0] || "文件";
const fileExtension = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择图标
let fileIcon = "📄";
if (fileExtension) {
const iconMap: { [key: string]: string } = {
pdf: "📕",
doc: "📘",
docx: "📘",
xls: "📗",
xlsx: "📗",
ppt: "📙",
pptx: "📙",
txt: "📝",
zip: "🗜️",
rar: "🗜️",
"7z": "🗜️",
jpg: "🖼️",
jpeg: "🖼️",
png: "🖼️",
gif: "🖼️",
mp4: "🎬",
avi: "🎬",
mov: "🎬",
mp3: "🎵",
wav: "🎵",
flac: "🎵",
};
fileIcon = iconMap[fileExtension] || "📄";
}
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
)}
<div
className={styles.fileAction}
onClick={() => {
try {
window.open(messageData.url, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
>
</div>
</div>
</div>
</div>
</div>
);
}
}
// 增强的文件消息处理
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);
if (isFileUrl) {
// 尝试从URL中提取文件名
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
const fileExtension = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择图标
let fileIcon = "📄";
if (fileExtension) {
const iconMap: { [key: string]: string } = {
pdf: "📕",
doc: "📘",
docx: "📘",
xls: "📗",
xlsx: "📗",
ppt: "📙",
pptx: "📙",
txt: "📝",
zip: "🗜️",
rar: "🗜️",
"7z": "🗜️",
jpg: "🖼️",
jpeg: "🖼️",
png: "🖼️",
gif: "🖼️",
mp4: "🎬",
avi: "🎬",
mov: "🎬",
mp3: "🎵",
wav: "🎵",
flac: "🎵",
};
fileIcon = iconMap[fileExtension] || "📄";
);
}
}
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div
className={styles.fileAction}
onClick={() => {
try {
window.open(content, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
>
</div>
</div>
</div>
</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("[小程序/文件消息]");

View File

@@ -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

View File

@@ -57,24 +57,22 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 如果content是直接的视频链接已预览过或下载好的视频
if (isDirectVideoLink(content)) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={content}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={content}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
);
@@ -109,24 +107,22 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
);
@@ -134,38 +130,36 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={e => handlePlayClick(e, msg)}
>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={e => handlePlayClick(e, msg)}
>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,4 @@
import request from "@/api/request2";
export const getWechatAccountInfo = (params: { id?: string }) => {
return request("/api/wechataccount", params, "GET");
};

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -13,6 +13,8 @@
margin-bottom: 16px;
padding: 0;
background: #fff;
display: flex;
gap: 10px;
}
.tabsContainer {

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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));

View File

@@ -53,7 +53,7 @@ const messageHandlers: Record<string, MessageHandler> = {
}
},
CmdSendMessageResult: message => {
updateMessage(message.friendMessageId, {
updateMessage(message.friendMessageId || message.chatroomMessageId, {
sendStatus: 0,
});
},