feat: 后端全量更新 - 含所有本次需求

- Contract.php: 返回合约账户余额(balance_contract)
- My.php: 地址管理增加BTC/ETH
- AppContract.php: 一键平仓(closeall)
- AppProxy.php: 代理专属注册链接 + 分级权限(L1/L2)
- site.php: 手续费减半(0.018→0.009)
- agent_permission_setup.sql: 代理权限SQL
- crypto_news_crawler.py: 新闻自动采集脚本
This commit is contained in:
li
2026-03-30 20:16:32 +08:00
commit 1b24994e74
6721 changed files with 1308571 additions and 0 deletions
+1
View File
File diff suppressed because one or more lines are too long
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace addons\kefu;
use app\common\library\Menu;
use think\Addons;
use think\Db;
/**
* 插件
*/
class Kefu extends Addons
{
/**
* 插件安装方法
* @return bool
*/
public function install()
{
// 创建菜单
$menu = [
[
'name' => 'kefu',
'title' => '客服管理',
'icon' => 'fa fa-comment',
'sublist' => [
[
'name' => 'kefu/config',
'title' => '客服配置',
'icon' => 'fa fa-circle-o',
'weigh' => '99',
'sublist' => [
['name' => 'kefu/config/index', 'title' => '查看'],
['name' => 'kefu/config/update', 'title' => '编辑'],
]
],
[
'name' => 'kefu/user',
'title' => '用户管理',
'icon' => 'fa fa-circle-o',
'weigh' => '98',
'sublist' => [
['name' => 'kefu/user/index', 'title' => '查看'],
['name' => 'kefu/user/edit', 'title' => '编辑'],
['name' => 'kefu/user/del', 'title' => '删除'],
]
],
[
'name' => 'kefu/session',
'title' => '会话管理',
'icon' => 'fa fa-circle-o',
'weigh' => '97',
'sublist' => [
['name' => 'kefu/session/index', 'title' => '查看'],
['name' => 'kefu/session/del', 'title' => '删除'],
['name' => 'kefu/session/recyclebin', 'title' => '回收站'],
['name' => 'kefu/session/destroy', 'title' => '真实删除'],
['name' => 'kefu/session/restore', 'title' => '还原'],
]
],
[
'name' => 'kefu/kbs',
'title' => '知识库管理',
'icon' => 'fa fa-circle-o',
'weigh' => '96',
'sublist' => [
['name' => 'kefu/kbs/index', 'title' => '查看'],
['name' => 'kefu/kbs/add', 'title' => '增加'],
['name' => 'kefu/kbs/edit', 'title' => '编辑'],
['name' => 'kefu/kbs/del', 'title' => '删除'],
['name' => 'kefu/kbs/multi', 'title' => '批量更新'],
['name' => 'kefu/kbs/recyclebin', 'title' => '回收站'],
['name' => 'kefu/kbs/destroy', 'title' => '真实删除'],
['name' => 'kefu/kbs/restore', 'title' => '还原'],
]
],
[
'name' => 'kefu/csrkpi',
'title' => '客服代表管理',
'icon' => 'fa fa-circle-o',
'weigh' => '95',
'sublist' => [
['name' => 'kefu/csrkpi/index', 'title' => '查看'],
['name' => 'kefu/csrkpi/add', 'title' => '添加'],
['name' => 'kefu/csrkpi/edit', 'title' => '编辑'],
['name' => 'kefu/csrkpi/del', 'title' => '删除'],
['name' => 'kefu/csrkpi/multi', 'title' => '批量更新']
]
],
[
'name' => 'kefu/leavemessage',
'title' => '用户留言管理',
'icon' => 'fa fa-circle-o',
'weigh' => '94',
'sublist' => [
['name' => 'kefu/leavemessage/index', 'title' => '查看'],
['name' => 'kefu/leavemessage/edit', 'title' => '编辑'],
['name' => 'kefu/leavemessage/del', 'title' => '删除']
]
],
[
'name' => 'kefu/record',
'title' => '聊天记录汇总',
'icon' => 'fa fa-circle-o',
'weigh' => '93',
'sublist' => [
['name' => 'kefu/record/index', 'title' => '查看'],
['name' => 'kefu/record/edit', 'title' => '编辑'],
['name' => 'kefu/record/del', 'title' => '删除'],
['name' => 'kefu/record/multi', 'title' => '批量更新']
]
],
[
'name' => 'kefu/fastreply',
'title' => '快捷回复管理',
'icon' => 'fa fa-circle-o',
'weigh' => '92',
'sublist' => [
['name' => 'kefu/fastreply/index', 'title' => '查看'],
['name' => 'kefu/fastreply/add', 'title' => '增加'],
['name' => 'kefu/fastreply/edit', 'title' => '编辑'],
['name' => 'kefu/fastreply/del', 'title' => '删除'],
['name' => 'kefu/fastreply/multi', 'title' => '批量更新'],
['name' => 'kefu/fastreply/recyclebin', 'title' => '回收站'],
['name' => 'kefu/fastreply/destroy', 'title' => '真实删除'],
['name' => 'kefu/fastreply/restore', 'title' => '还原'],
]
],
[
'name' => 'kefu/blacklist',
'title' => '用户黑名单管理',
'icon' => 'fa fa-circle-o',
'weigh' => '91',
'sublist' => [
['name' => 'kefu/blacklist/index', 'title' => '查看'],
['name' => 'kefu/blacklist/add', 'title' => '增加'],
['name' => 'kefu/blacklist/edit', 'title' => '编辑'],
['name' => 'kefu/blacklist/del', 'title' => '删除'],
['name' => 'kefu/blacklist/recyclebin', 'title' => '回收站'],
['name' => 'kefu/blacklist/destroy', 'title' => '真实删除'],
['name' => 'kefu/blacklist/restore', 'title' => '还原'],
]
],
[
'name' => 'kefu/toolbar',
'title' => '窗口工具栏管理',
'icon' => 'fa fa-circle-o',
'weigh' => '90',
'remark' => '此功能用于管理会话窗口工具栏基本信息及状态,若需添加自定义工具,请先自行实现对应功能',
'sublist' => [
['name' => 'kefu/toolbar/index', 'title' => '查看'],
['name' => 'kefu/toolbar/add', 'title' => '增加'],
['name' => 'kefu/toolbar/edit', 'title' => '编辑'],
['name' => 'kefu/toolbar/del', 'title' => '删除'],
['name' => 'kefu/toolbar/recyclebin', 'title' => '回收站'],
['name' => 'kefu/toolbar/destroy', 'title' => '真实删除'],
['name' => 'kefu/toolbar/restore', 'title' => '还原'],
]
],
]
]
];
Menu::create($menu);
return true;
}
/**
* 插件更新方法
* @return bool
*/
public function upgrade()
{
// v1.0.2 审查聊天记录
if (!Db::name('auth_rule')->where('name', 'kefu/record/sessionRecord')->value('id')) {
$menu = [
['name' => 'kefu/record/sessionRecord', 'title' => '审查聊天记录']
];
Menu::create($menu, 'kefu/record');
}
// v1.0.3 知识库
if (!Db::name('auth_rule')->where('name', 'kefu/kbs')->value('id')) {
$menu = [
[
'name' => 'kefu/kbs',
'title' => '知识库管理',
'icon' => 'fa fa-circle-o',
'sublist' => [
['name' => 'kefu/kbs/index', 'title' => '查看'],
['name' => 'kefu/kbs/add', 'title' => '增加'],
['name' => 'kefu/kbs/edit', 'title' => '编辑'],
['name' => 'kefu/kbs/del', 'title' => '删除'],
['name' => 'kefu/kbs/multi', 'title' => '批量更新'],
['name' => 'kefu/kbs/recyclebin', 'title' => '回收站'],
['name' => 'kefu/kbs/destroy', 'title' => '真实删除'],
['name' => 'kefu/kbs/restore', 'title' => '还原'],
]
]
];
Menu::create($menu, 'kefu');
}
// v1.0.3 客服代表管理
$kefu_csrkpi_menu = Db::name('auth_rule')->where('name', 'kefu/csrkpi')->find();
if ($kefu_csrkpi_menu['title'] == '客服绩效报表') {
Db::name('auth_rule')->where('name', 'kefu/csrkpi')->update(['title' => '客服代表管理']);
}
if (!Db::name('auth_rule')->where('name', 'kefu/csrkpi/add')->value('id')) {
$menu = [
['name' => 'kefu/csrkpi/add', 'title' => '添加'],
['name' => 'kefu/csrkpi/edit', 'title' => '编辑'],
['name' => 'kefu/csrkpi/del', 'title' => '删除'],
];
Menu::create($menu, 'kefu/csrkpi');
}
// v1.0.4 窗口工具栏管理
if (!Db::name('auth_rule')->where('name', 'kefu/toolbar')->value('id')) {
$menu = [
[
'name' => 'kefu/toolbar',
'title' => '窗口工具栏管理',
'icon' => 'fa fa-circle-o',
'remark' => '此功能用于管理会话窗口工具栏基本信息及状态,若需添加自定义工具,请先自行实现对应功能',
'sublist' => [
['name' => 'kefu/toolbar/index', 'title' => '查看'],
['name' => 'kefu/toolbar/add', 'title' => '增加'],
['name' => 'kefu/toolbar/edit', 'title' => '编辑'],
['name' => 'kefu/toolbar/del', 'title' => '删除'],
['name' => 'kefu/toolbar/recyclebin', 'title' => '回收站'],
['name' => 'kefu/toolbar/destroy', 'title' => '真实删除'],
['name' => 'kefu/toolbar/restore', 'title' => '还原'],
]
]
];
Menu::create($menu, 'kefu');
}
// v1.0.6 修复客服配置功能权限分配bug
if (!Db::name('auth_rule')->where('name', 'kefu/config/update')->value('id')) {
$menu = [
['name' => 'kefu/config/update', 'title' => '编辑']
];
Menu::create($menu, 'kefu/config');
Db::name('auth_rule')->where('name', 'kefu/config/index')->update([
'title' => '查看'
]);
}
return true;
}
/**
* 插件卸载方法
* @return bool
*/
public function uninstall()
{
Menu::delete('kefu');
return true;
}
/**
* 插件启用方法
* @return bool
*/
public function enable()
{
$this->upgrade();
Menu::enable('kefu');
return true;
}
/**
* 插件禁用方法
* @return bool
*/
public function disable()
{
Menu::disable('kefu');
return true;
}
/**
* 增加命令
*/
public function appInit($param)
{
if (request()->isCli()) {
\think\Console::addDefaultCommands([
'addons\kefu\library\GatewayWorker\start'
]);
}
}
}
Vendored Executable
+80
View File
@@ -0,0 +1,80 @@
if (Config.modulename == 'admin' && Config.controllername == 'index' && Config.actionname == 'index') {
require.config({
paths: {
'kefu': '../addons/kefu/js/kefu'
},
shim: {
'kefu': {
deps: ['css!../addons/kefu/css/kefu_admin_default.css'],
exports: 'KeFu'
}
}
});
require(['kefu'], function (KeFu) {
KeFu.initialize(document.domain, 'admin');
});
} else {
try {
var parentConifg = window.parent.Config;
} catch (err) {
var parentConifg = false;
}
if (parentConifg && parentConifg.modulename == 'admin') {
// 监听后台iframe内的快捷键打开会话窗口
$(document).on('keyup', function (event) {
if (window.parent.KeFu) {
// console.log('当前按钮的code-iframe内:', event.keyCode);
// 对打开会话窗口的监听
// 打开会话窗口快捷键[ctrl + /],若需修改,请拿到对应键的keyCode替换下一行的191即可,191代表[/]键的keyCode
if (event.keyCode === 191 && event.ctrlKey) {
if (window.parent.KeFu.last_sender) {
if (parseInt(window.parent.KeFu.last_sender) === window.parent.KeFu.session_id) {
// 展开分组
if (!window.parent.KeFu.group_show.dialogue) {
$('#heading_dialogue a').click();
}
} else {
window.parent.KeFu.changeSession(window.parent.KeFu.last_sender);
window.parent.KeFu.last_sender = null;
}
} else if (window.parent.KeFu.window_is_show) {
window.parent.KeFu.toggle_window('hide');
}
if (!window.parent.KeFu.window_is_show) {
window.parent.KeFu.toggle_window('show');
}
return ;
}
}
});
} else {
require.config({
paths: {
'kefu': '../addons/kefu/js/kefu'
},
shim: {
'kefu': {
deps: ['css!../addons/kefu/css/kefu_default.css'],
exports: 'KeFu'
}
}
});
require(['kefu'], function (KeFu) {
KeFu.initialize(document.domain, 'index');
});
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
return [
[
'name' => '__tips__',
'title' => '温馨提示',
'type' => 'string',
'content' => [],
'value' => '1. <b><font color="red">本窗口的所有配置项,非技术人员不建议进行调整</font></b><br>'."\n"
.' 2. 若需开启wss协议,请先配置<b>ssl证书</b>、<b>ssl证书KEY</b>并重启Workerman服务,才会生效;https站点必须配置wss<br>'."\n"
.' 3. 仅需对外开放<b>WebSocket端口</b>,另外的两项端口<b>未被占用</b>即可<br>'."\n"
.' 4. 消息提醒铃声建议大小100kb,文件无法上传请参考插件文档<a target="_blank" href="https://doc.fastadmin.net/kefu/38.html">常见问题</a>',
'rule' => '',
'msg' => '',
'tip' => '',
'ok' => '',
'extend' => '',
],
[
'name' => 'wss_switch',
'title' => 'wss协议',
'type' => 'radio',
'content' => [
'不开启',
'开启',
],
'value' => '1',
'rule' => '',
'tip' => '请先参考常见问题配置好wss服务再开启,否则将无法链接',
'ok' => '',
'extend' => '',
],
[
'name' => 'ssl_cert',
'title' => 'ssl证书',
'type' => 'string',
'content' => [],
'value' => '/www/wwwroot/jyshd/cert/server.pem',
'rule' => '',
'tip' => '请填写证书pem或crt文件的绝对路径',
'ok' => '',
'extend' => '',
],
[
'name' => 'ssl_cert_key',
'title' => 'ssl证书KEY',
'type' => 'string',
'content' => [],
'value' => '/www/wwwroot/jyshd/cert/server.key',
'rule' => '',
'tip' => '请填写证书密匙(key)文件的绝对路径',
'ok' => '',
'extend' => '',
],
[
'name' => 'websocket_port',
'title' => 'WebSocket端口',
'type' => 'string',
'content' => [],
'value' => '11818',
'rule' => 'required,range(1024~65535)',
'tip' => '请在安全组、防火墙等开放此端口',
'ok' => '',
'extend' => '',
],
[
'name' => 'register_port',
'title' => '服务注册端口',
'type' => 'string',
'content' => [],
'value' => '1819',
'rule' => 'required,range(1024~65535)',
'tip' => '无需对外开放,属未被占用的端口即可',
'ok' => '',
'extend' => '',
],
[
'name' => 'internal_start_port',
'title' => '内部通讯起始端口',
'type' => 'string',
'content' => [],
'value' => '3200',
'rule' => 'required,range(1024~65535)',
'tip' => '无需对外开放,属未被占用的端口即可',
'ok' => '',
'extend' => '',
],
[
'name' => 'gateway_process_number',
'title' => 'Gateway进程数',
'type' => 'string',
'content' => [],
'value' => '1',
'rule' => '',
'tip' => '设置为CPU核数相等的数量性能最好',
'ok' => '',
'extend' => '',
],
[
'name' => 'worker_process_number',
'title' => 'BusinessWorker进程数',
'type' => 'string',
'content' => [],
'value' => '2',
'rule' => '',
'tip' => '根据业务有无阻塞式IO,设为CPU核数的1-3倍',
'ok' => '',
'extend' => '',
],
[
'name' => 'ringing',
'title' => '消息提醒铃声',
'type' => 'file',
'content' => [],
'value' => '/assets/addons/kefu/audio/message_prompt.wav',
'rule' => 'required,file',
'tip' => '',
'ok' => '',
'extend' => '',
],
[
'name' => 'theme',
'title' => '主题模板',
'type' => 'string',
'content' => [],
'value' => 'default',
'rule' => 'required',
'msg' => '',
'tip' => '请确保addons/kefu/view有相应的目录',
'ok' => '',
'extend' => '',
],
[
'name' => 'allow_domain',
'title' => '跨站调用允许域名',
'type' => 'text',
'content' => [],
'value' => '*',
'rule' => '',
'tip' => '一行一个,未在列表内的外站无法引用插件',
'ok' => '',
'extend' => '',
],
[
'name' => 'rule_out_url',
'title' => '以下页面不启动<br>(一行一个)',
'type' => 'text',
'content' => [],
'value' => 'http://kefu_local.com/index/user/index.html?tip=自动排除对应https地址、带参数地址,此处的URL带参数无效',
'rule' => '',
'tip' => '这些前台页面将不启动在线客服',
'ok' => '',
'extend' => '',
],
];
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace addons\kefu\controller;
use think\addons\Controller;
use think\Request;
class Base extends Controller
{
public function __construct(Request $request = null)
{
parent::__construct($request);
$config = get_addon_config('kefu');
// 设定主题模板目录
$this->view->engine->config('view_path', $this->view->engine->config('view_path') . trim($config['theme']) . DS);
}
protected function _initialize()
{
parent::_initialize();
}
}
+493
View File
@@ -0,0 +1,493 @@
<?php
namespace addons\kefu\controller;
use addons\kefu\library\Common;
use fast\Random;
use think\Config;
use think\Db;
use think\Cookie;
class Index extends Base
{
protected $noNeedLogin = ['initialize', 'loadMessagePrompt', 'upload', 'mobile', 'index'];
protected $chat_config;
protected $token_info = false;// 用户、游客、管理员的资料
protected $token_list = [];// 要发送给前台的token(前台利用这些token链接websocket
protected $referrer = '';
public function index()
{
$this->view->assign('chat_name', $this->chat_config['chat_name']);
return $this->view->fetch();
}
public function mobile()
{
$this->view->assign('toolbar', $this->chat_config['toolbar']);// 工具栏配置
$this->view->assign('chat_name', $this->chat_config['chat_name']);
return $this->view->fetch();
}
/**
* 获取配置、初始化AJAX请求过来的用户的身份等
*/
public function _initialize()
{
parent::_initialize();
$this->referrer = Common::trajectoryAnalysis($this->request->get('referrer'));
// 读取工具栏
$toolbar_temp = Db::name('kefu_toolbar')->where('status', 1)->where('deletetime', null)->select();
// 以mark为键
foreach ($toolbar_temp as $key => $value) {
$value['icon_image'] = Common::imgSrcFill($value['icon_image'], false);
$toolbar['toolbar'][$value['mark']] = $value;
}
$this->chat_config = Db::name('kefu_config')->column('name,value');
$kefu_config = get_addon_config('kefu');
$kefu_config['__CDN__'] = config('view_replace_str.__CDN__');
$kefu_config['__CDN__'] = $kefu_config['__CDN__'] ? $kefu_config['__CDN__'] : $this->request->domain();
// 上传配置
$upload['upload'] = \app\common\model\Config::upload();
// 上传信息配置后
\think\Hook::listen("upload_config_init", $upload['upload']);
if ($upload['upload']['storage'] != 'local') {
$upload['upload']['cdnurl'] = $upload['upload']['cdnurl'] ? $upload['upload']['cdnurl'] : cdnurl('', true);
$upload['upload']['uploadurl'] = preg_match('/^http(s)?:\/\//', $upload['upload']['uploadurl']) ? $upload['upload']['uploadurl'] : $this->request->domain() . $upload['upload']['uploadurl'];
} else {
$upload['upload']['cdnurl'] = $this->request->domain();
$upload['upload']['uploadurl'] = addon_url('kefu/index/upload', [], '', true);
}
$this->chat_config = array_merge($this->chat_config, $kefu_config, $upload, $toolbar);
unset($toolbar_temp);
unset($toolbar);
// 跨域配置
$allow_domain = explode(PHP_EOL, trim($this->chat_config['allow_domain'], PHP_EOL));
$allow_domain = array_map("trim", $allow_domain);// 去除每个域名前后的空格
$http_origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : false;
if ($http_origin && (in_array('*', $allow_domain) || in_array($http_origin, $allow_domain))) {
header('Vary: Origin');
header('Access-Control-Allow-Origin: ' . $http_origin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400');
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
}
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");
}
exit;
}
} else {
if (isset($_SERVER['HTTP_ORIGIN']) && preg_replace('/^http(s)?:\/\//', '', $http_origin) != $_SERVER['HTTP_HOST']) {
// $this->result(null, 0, '您的站点未被允许调用' . $this->chat_config['chat_name'], 'json');
}
}
// 页面排除
if ($this->chat_config['rule_out_url'] && isset($_SERVER['HTTP_REFERER'])) {
$http_referer = $this->urlDealWith($_SERVER['HTTP_REFERER']);
$rule_out_url = explode(PHP_EOL, $this->chat_config['rule_out_url']);
foreach ($rule_out_url as $key => $value) {
if ($this->urlDealWith($value) == $http_referer) {
$this->result(null, 401, $this->chat_config['chat_name'] . ' 当前页面被设置为不启动', 'json');
}
}
}
// 用户登录
$data = $this->request->only(['modulename', 'token', 'kefu_tourists_token']);
$this->token_list['kefu_tourists_token'] = Cookie::get('kefu_user');
if (!$this->token_list['kefu_tourists_token'] && isset($data['kefu_tourists_token'])) {
$this->token_list['kefu_tourists_token'] = $data['kefu_tourists_token'];
}
if (!isset($data['modulename'])) {
if ($this->request->action() == 'mobile' || $this->request->action() == 'index') {
$data['modulename'] = 'index';
} else {
$this->result(null, 0, $this->chat_config['chat_name'] . ' 模块未知' . $this->request->action(), 'json');
}
}
if ($data['modulename'] == 'admin') {
// 验证管理员身份
$auth = \app\admin\library\Auth::instance();
if ($auth->isLogin()) {
$this->token_info = Common::checkAdmin(false, $auth->id);
if ($this->token_info) {
// workerman 中不支持 PHP session和cookie,所以 $auth 类失效
// 此处对管理员 token 加密稍作修改,供客服自动登录使用
$keeptime = 864000;
$expiretime = time() + $keeptime;
// 原规则为单纯的id,若需修改附加的字符串,请将`Common::checkAdmin`方法里边的附加字符串一起修改
$sign = $this->token_info['id'] . 'kefu_admin_sign_additional';
$key = md5(md5($sign) . md5($keeptime) . md5($expiretime) . $this->token_info['token']);
$cookie_data = [$this->token_info['id'], $keeptime, $expiretime, $key];
$this->token_list['kefu_token'] = implode('|', $cookie_data);
unset($this->token_info['token']);
}
}
// 清理轨迹
switch ($this->chat_config['trajectory_save_cycle']) {
case 0:
$where_time = 604800; // 清理7天前的
break;
case 1:
$where_time = 2592000; // 30天
break;
case 2:
$where_time = 5184000; // 60天
break;
default:
$where_time = false; // 不清理
break;
}
if ($where_time) {
Db::name('kefu_trajectory')->where('createtime', '<', time() - $where_time)->delete();
}
} elseif ($data['modulename'] != 'admin') {
// 验证用户身份
$auth = \app\common\library\Auth::instance();
$token = Cookie::get('token');
$token = (!$token && isset($data['token'])) ? $data['token'] : $token;
if ($token) {
$auth->init($token);
if ($auth->isLogin()) {
$this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], $auth->id);
if (!$this->token_info && !$this->token_list['kefu_tourists_token']) {
// 该用户未绑定游客,建立游客身份并绑定
$tourists = Common::createTourists($this->referrer . ' IP:' . $this->request->ip());
if ($tourists) {
$this->token_list['kefu_tourists_token'] = $tourists['kefu_user_cookie'];
Cookie::set('kefu_user', $tourists['kefu_user_cookie'], 315360000);
} else {
$this->result(null, 401, $this->chat_config['chat_name'] . ' 游客创建失败!', 'json');
}
$this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], $auth->id);
}
// workerman 中不支持 PHP session和cookie,所以 $auth 类失效
// 在开启 $cookie_httponly 时,对用户的 token 加密稍作修改,供客服自动登录使用
if ($this->token_info) {
$cookie_httponly = config('cookie.httponly');
if (!$cookie_httponly) {
$this->token_list['kefu_token'] = $token;
} else {
// 若需修改附加的字符串,请将`Events::onWebSocketConnect`方法里边的附加字符串一起修改
// 先用 user_token 数据表中的token字段同样的加密算法对token进行加密,否则workerman无法识别用户身份
$sign = Common::getEncryptedToken($token) . 'kefu_user_sign_additional';
$key = md5(md5($auth->id) . md5($sign));
$cookie_data = [$auth->id, $key];
$this->token_list['kefu_token'] = implode('|', $cookie_data);
}
}
}
}
}
if (!$this->token_list['kefu_tourists_token'] && !$this->token_info) {
$tourists = Common::createTourists($this->referrer . ' IP:' . $this->request->ip());
if ($tourists) {
$this->token_list['kefu_tourists_token'] = $tourists['kefu_user_cookie'];
Cookie::set('kefu_user', $tourists['kefu_user_cookie'], 315360000);
} else {
$this->result(null, 401, $this->chat_config['chat_name'] . ' 游客创建失败!', 'json');
}
}
if ($data['modulename'] != 'admin' && $this->token_list['kefu_tourists_token'] && !$this->token_info) {
// 验证游客用户身份
$this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], 0);
}
$this->view->assign('cdnurl', $kefu_config['__CDN__']);
}
public function initialize()
{
$res_data = [];
$data = $this->request->only(['modulename']);
$current_url = $this->request->header('referer');
if ($this->token_info) {
if (isset($this->token_info['blacklist']) && $this->token_info['blacklist']) {
$this->result(null, 401, '!', 'json');// 黑名单用户
}
} else {
$this->result(null, 401, $this->chat_config['chat_name'] . ' 无法识别用户!', 'json');
}
$this->view->assign('toolbar', $this->chat_config['toolbar']);// 工具栏配置
if ($data['modulename'] == 'admin') {
if (isset($this->chat_config['toolbar']['fastreply'])) {
// 快捷回复
$fast_reply = Db::name('kefu_fast_reply')
->where('admin_id=' . $this->token_info['id'] . ' OR admin_id=0')
->where('status', '1')
->where('deletetime', null)
->select();
$fast_reply_temp = [];
foreach ($fast_reply as $key => $value) {
$fast_reply_temp[$value['id']] = $value;
}
unset($fast_reply);
$res_data['fast_reply'] = $fast_reply_temp;
$this->view->assign('fast_reply', $fast_reply_temp);
}
$res_data['window_html'] = $this->view->fetch(ROOT_PATH . 'addons/kefu/view/' . trim($this->chat_config['theme']) . '/modaltpl/admin.html');
} else {
$this->chat_config['invite_box_img'] = $this->chat_config['invite_box_img'] ? Common::imgSrcFill($this->chat_config['invite_box_img'], false) : false;
$this->chat_config['auto_invitation_switch'] = ($this->chat_config['invite_box_img']) ? $this->chat_config['auto_invitation_switch'] : 0;
// 只在有客服在线时弹出邀请框
if ($this->chat_config['only_csr_online_invitation'] && $this->chat_config['auto_invitation_switch']) {
$online_csr = Db::name('kefu_csr_config')
->where('status', 3)
->value('admin_id');
$this->chat_config['auto_invitation_switch'] = $online_csr ? $this->chat_config['auto_invitation_switch'] : 0;
}
// 前台轮播图
$this->chat_config['slider_images'] = $this->chat_config['slider_images'] ? explode(',', trim($this->chat_config['slider_images'], ',')) : [];
foreach ($this->chat_config['slider_images'] as $key => $value) {
$this->chat_config['slider_images'][$key] = Common::imgSrcFill($value, false);
}
$res_data['window_html'] = $this->view->fetch(ROOT_PATH . 'addons/kefu/view/' . trim($this->chat_config['theme']) . '/modaltpl/index.html');
}
// 记录轨迹
if (isset($this->token_info['trajectory'])) {
// 用户轨迹
$trajectory = [
'user_id' => $this->token_info['id'],
'csr_id' => $this->token_info['trajectory']['csr_id'],
'log_type' => 0,
'note' => $this->token_info['trajectory']['note'],
'url' => $current_url,
'referrer' => $this->referrer,
'createtime' => time(),
];
Db::name('kefu_trajectory')->insert($trajectory);
}
// 窗口抖动配置处理
$this->chat_config['is_shake'] = false;
if ($this->chat_config['new_message_shake'] == 3) {
$this->chat_config['is_shake'] = true;
} elseif ($this->chat_config['new_message_shake'] == 1 && $data['modulename'] != 'admin') {
$this->chat_config['is_shake'] = true;
} elseif ($this->chat_config['new_message_shake'] == 2 && $data['modulename'] == 'admin') {
$this->chat_config['is_shake'] = true;
}
// 配置排除
$except_config = [
'allow_domain',
'wechat_app_id',
'wechat_app_secret',
'wechat_encodingkey',
'wechat_token',
'worker_process_number',
'csr_admin',
'csr_distribution',
'register_port',
'gateway_process_number',
'internal_start_port',
'kbs_switch',
'trajectory_save_cycle',
'ssl_cert',
'ssl_cert_key'
];
foreach ($except_config as $key => $value) {
if (in_array($value, $except_config)) {
unset($this->chat_config[$value]);
}
}
// 防止商品和订单卡片暴露后台地址
if ($this->token_info['source'] != 'csr') {
unset($this->chat_config['toolbar']['goods']['card_url']);
unset($this->chat_config['toolbar']['order']['card_url']);
}
$res_data['user_info'] = $this->token_info;
$res_data['new_msg'] = Common::getUnreadMessages($this->token_info['user_id']);
$this->chat_config['modulename'] = $data['modulename'];
$res_data['config'] = $this->chat_config;
$res_data['token_list'] = $this->token_list;
$this->result($res_data, 1, 'ok', 'json');
}
/**
* 去除URL中的 index.php、https://、http://、去除参数
* @param [type] $url [description]
* @return string 处理结果
*/
private function urlDealWith($url)
{
$url = explode('?', $url);
$url = isset($url[0]) ? $url[0] : ''; // 只要 ? 号前的字符串
return str_replace(['http://', 'https://'], '', trim($url));
}
/**
* 供跨站下载来信提示音文件(未使用云存储)
*/
public function loadMessagePrompt()
{
$file = ROOT_PATH . 'public' . $this->chat_config['ringing'];
header("Content-type:application/octet-stream");
$filename = basename($file);
header("Content-Disposition:attachment;filename = " . $filename);
header("Accept-ranges:bytes");
header("Accept-length:" . filesize($file));
readfile($file);
}
public function upload()
{
$file = $this->request->file('file');
if (empty($file)) {
$this->result(null, 0, '没有文件被上传或上传超过限制', 'json');
}
//判断是否已经存在附件
$sha1 = $file->hash();
$extparam = $this->request->post();
$upload = Config::get('upload');
preg_match('/(\d+)(\w+)/', $upload['maxsize'], $matches);
$type = strtolower($matches[2]);
$typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
$size = (int)$upload['maxsize'] * pow(1024, isset($typeDict[$type]) ? $typeDict[$type] : 0);
$fileInfo = $file->getInfo();
$suffix = strtolower(pathinfo($fileInfo['name'], PATHINFO_EXTENSION));
$suffix = $suffix ? $suffix : 'file';
$mimetypeArr = explode(',', strtolower($upload['mimetype']));
$typeArr = explode('/', $fileInfo['type']);
//禁止上传PHP和HTML文件
if (in_array($fileInfo['type'], ['text/x-php', 'text/html']) || in_array($suffix, ['php', 'html', 'htm'])) {
$this->error(__('上传格式限制'));
}
//验证文件后缀
if ($upload['mimetype'] !== '*' && (!in_array($suffix, $mimetypeArr) || (stripos($typeArr[0] . '/', $upload['mimetype']) !== false && (!in_array($fileInfo['type'], $mimetypeArr) && !in_array($typeArr[0] . '/*', $mimetypeArr))))) {
$this->error(__('上传格式限制'));
}
//验证是否为图片文件
$imagewidth = $imageheight = 0;
if (in_array($fileInfo['type'], [
'image/gif',
'image/jpg',
'image/jpeg',
'image/bmp',
'image/png',
'image/webp'
]) || in_array($suffix, ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
$imgInfo = getimagesize($fileInfo['tmp_name']);
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
$this->error(__('上传的文件不是图片'));
}
$imagewidth = isset($imgInfo[0]) ? $imgInfo[0] : $imagewidth;
$imageheight = isset($imgInfo[1]) ? $imgInfo[1] : $imageheight;
}
$replaceArr = [
'{year}' => date("Y"),
'{mon}' => date("m"),
'{day}' => date("d"),
'{hour}' => date("H"),
'{min}' => date("i"),
'{sec}' => date("s"),
'{random}' => Random::alnum(16),
'{random32}' => Random::alnum(32),
'{filename}' => $suffix ? substr($fileInfo['name'], 0, strripos($fileInfo['name'], '.')) : $fileInfo['name'],
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{filemd5}' => md5_file($fileInfo['tmp_name']),
];
$savekey = $upload['savekey'];
$savekey = str_replace(array_keys($replaceArr), array_values($replaceArr), $savekey);
$uploadDir = substr($savekey, 0, strripos($savekey, '/') + 1);
$fileName = substr($savekey, strripos($savekey, '/') + 1);
$splInfo = $file->validate(['size' => $size])->move(ROOT_PATH . '/public' . $uploadDir, $fileName);
if ($splInfo) {
$admin_id = 0;
$user_id = 0;
if ($this->token_info) {
$user_info = Common::userInfo($this->token_info['user_id']);
if ($user_info['session_type'] == 0) {
$user_id = $user_info['id'];
} elseif ($user_info['session_type'] == 1) {
$admin_id = $user_info['id'];
}
}
$params = [
'admin_id' => $admin_id,
'user_id' => $user_id,
'filesize' => $fileInfo['size'],
'imagewidth' => $imagewidth,
'imageheight' => $imageheight,
'imagetype' => $suffix,
'imageframes' => 0,
'mimetype' => $fileInfo['type'],
'url' => $uploadDir . $splInfo->getSaveName(),
'uploadtime' => time(),
'storage' => 'local',
'sha1' => $sha1,
'extparam' => json_encode($extparam),
];
$attachment = model("common/attachment");
$attachment->data(array_filter($params));
$attachment->save();
\think\Hook::listen("upload_after", $attachment);
$this->result(['url' => $uploadDir . $splInfo->getSaveName()], 1, null, 'json');
} else {
// 上传失败获取错误信息
$this->result(null, 1, $file->getError(), 'json');
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<script>
export default {
onLaunch: function() {
},
onShow: function() {
},
onHide: function() {
}
}
</script>
<style>
/*每个页面公共css */
</style>
@@ -0,0 +1,633 @@
<template>
<view>
<slot v-if="!nodes.length" />
<!--#ifdef APP-PLUS-NVUE-->
<web-view id="_top" ref="web" :style="'margin-top:-2px;height:'+height+'px'" @onPostMessage="_message" />
<!--#endif-->
<!--#ifndef APP-PLUS-NVUE-->
<view id="_top" :style="showAm+(selectable?';user-select:text;-webkit-user-select:text':'')">
<!--#ifdef H5 || MP-360-->
<div :id="'rtf'+uid"></div>
<!--#endif-->
<!--#ifndef H5 || MP-360-->
<trees :nodes="nodes" :lazyLoad="lazyLoad" :loading="loadingImg" />
<!--#endif-->
</view>
<!--#endif-->
</view>
</template>
<script>
// #ifndef H5 || APP-PLUS-NVUE || MP-360
import trees from './libs/trees';
var cache = {},
// #ifdef MP-WEIXIN || MP-TOUTIAO
fs = uni.getFileSystemManager ? uni.getFileSystemManager() : null,
// #endif
Parser = require('./libs/MpHtmlParser.js');
var dom;
// 计算 cache 的 key
function hash(str) {
for (var i = str.length, val = 5381; i--;)
val += (val << 5) + str.charCodeAt(i);
return val;
}
// #endif
// #ifdef H5 || APP-PLUS-NVUE || MP-360
var rpx = uni.getSystemInfoSync().windowWidth / 750,
cfg = require('./libs/config.js');
// #endif
// #ifdef APP-PLUS-NVUE
var weexDom = weex.requireModule('dom');
// #endif
/**
* Parser 富文本组件
* @tutorial https://github.com/jin-yufeng/Parser
* @property {String} html 富文本数据
* @property {Boolean} autopause 是否在播放一个视频时自动暂停其他视频
* @property {Boolean} autoscroll 是否自动给所有表格添加一个滚动层
* @property {Boolean} autosetTitle 是否自动将 title 标签中的内容设置到页面标题
* @property {Number} compress 压缩等级
* @property {String} domain 图片、视频等链接的主域名
* @property {Boolean} lazyLoad 是否开启图片懒加载
* @property {String} loadingImg 图片加载完成前的占位图
* @property {Boolean} selectable 是否开启长按复制
* @property {Object} tagStyle 标签的默认样式
* @property {Boolean} showWithAnimation 是否使用渐显动画
* @property {Boolean} useAnchor 是否使用锚点
* @property {Boolean} useCache 是否缓存解析结果
* @event {Function} parse 解析完成事件
* @event {Function} load dom 加载完成事件
* @event {Function} ready 所有图片加载完毕事件
* @event {Function} error 错误事件
* @event {Function} imgtap 图片点击事件
* @event {Function} linkpress 链接点击事件
* @author JinYufeng
* @version 20200615
* @listens MIT
*/
export default {
name: 'parser',
data() {
return {
// #ifdef H5 || MP-360
uid: this._uid,
// #endif
// #ifdef APP-PLUS-NVUE
height: 1,
// #endif
// #ifndef APP-PLUS-NVUE
showAm: '',
// #endif
nodes: []
}
},
// #ifndef H5 || APP-PLUS-NVUE || MP-360
components: {
trees
},
// #endif
props: {
html: String,
autopause: {
type: Boolean,
default: true
},
autoscroll: Boolean,
autosetTitle: {
type: Boolean,
default: true
},
// #ifndef H5 || APP-PLUS-NVUE || MP-360
compress: Number,
loadingImg: String,
useCache: Boolean,
// #endif
domain: String,
lazyLoad: Boolean,
selectable: Boolean,
tagStyle: Object,
showWithAnimation: Boolean,
useAnchor: Boolean
},
watch: {
html(html) {
this.setContent(html);
}
},
created() {
// 图片数组
this.imgList = [];
this.imgList.each = function(f) {
for (var i = 0, len = this.length; i < len; i++)
this.setItem(i, f(this[i], i, this));
}
this.imgList.setItem = function(i, src) {
if (i == void 0 || !src) return;
// #ifndef MP-ALIPAY || APP-PLUS
// 去重
if (src.indexOf('http') == 0 && this.includes(src)) {
var newSrc = src.split('://')[0];
for (var j = newSrc.length, c; c = src[j]; j++) {
if (c == '/' && src[j - 1] != '/' && src[j + 1] != '/') break;
newSrc += Math.random() > 0.5 ? c.toUpperCase() : c;
}
newSrc += src.substr(j);
return this[i] = newSrc;
}
// #endif
this[i] = src;
// 暂存 data src
if (src.includes('data:image')) {
var filePath, info = src.match(/data:image\/(\S+?);(\S+?),(.+)/);
if (!info) return;
// #ifdef MP-WEIXIN || MP-TOUTIAO
filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.${info[1]}`;
fs && fs.writeFile({
filePath,
data: info[3],
encoding: info[2],
success: () => this[i] = filePath
})
// #endif
// #ifdef APP-PLUS
filePath = `_doc/parser_tmp/${Date.now()}.${info[1]}`;
var bitmap = new plus.nativeObj.Bitmap();
bitmap.loadBase64Data(src, () => {
bitmap.save(filePath, {}, () => {
bitmap.clear()
this[i] = filePath;
})
})
// #endif
}
}
},
mounted() {
// #ifdef H5 || MP-360
this.document = document.getElementById('rtf' + this._uid);
// #endif
// #ifndef H5 || APP-PLUS-NVUE || MP-360
if (dom) this.document = new dom(this);
// #endif
// #ifdef APP-PLUS-NVUE
this.document = this.$refs.web;
setTimeout(() => {
// #endif
if (this.html) this.setContent(this.html);
// #ifdef APP-PLUS-NVUE
}, 30)
// #endif
},
beforeDestroy() {
// #ifdef H5 || MP-360
if (this._observer) this._observer.disconnect();
// #endif
this.imgList.each(src => {
// #ifdef APP-PLUS
if (src && src.includes('_doc')) {
plus.io.resolveLocalFileSystemURL(src, entry => {
entry.remove();
});
}
// #endif
// #ifdef MP-WEIXIN || MP-TOUTIAO
if (src && src.includes(uni.env.USER_DATA_PATH))
fs && fs.unlink({
filePath: src
})
// #endif
})
clearInterval(this._timer);
},
methods: {
// #ifdef H5 || APP-PLUS-NVUE || MP-360
_handleHtml(html, append) {
if (!append) {
// 处理 tag-style 和 userAgentStyles
var style = '<style>@keyframes _show{0%{opacity:0}100%{opacity:1}}img{max-width:100%}';
for (var item in cfg.userAgentStyles)
style += `${item}{${cfg.userAgentStyles[item]}}`;
for (item in this.tagStyle)
style += `${item}{${this.tagStyle[item]}}`;
style += '</style>';
html = style + html;
}
// 处理 rpx
if (html.includes('rpx'))
html = html.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * rpx + 'px');
return html;
},
// #endif
setContent(html, append) {
// #ifdef APP-PLUS-NVUE
if (!html)
return this.height = 1;
if (append)
this.$refs.web.evalJs("var b=document.createElement('div');b.innerHTML='" + html.replace(/'/g, "\\'") +
"';document.getElementById('parser').appendChild(b)");
else {
html =
'<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><base href="' +
this.domain + '"><div id="parser"' + (this.selectable ? '>' : ' style="user-select:none">') + this._handleHtml(html).replace(/\n/g, '\\n') +
'</div><script>"use strict";function e(e){if(window.__dcloud_weex_postMessage||window.__dcloud_weex_){var t={data:[e]};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(t):window.__dcloud_weex_.postMessage(JSON.stringify(t))}}' +
(this.showWithAnimation ? 'document.body.style.animation="_show .5s",' : '') +
'setTimeout(function(){e({action:"load",text:document.body.innerText,height:document.getElementById("parser").scrollHeight+16})},50);\x3c/script>';
this.$refs.web.evalJs("document.write('" + html.replace(/'/g, "\\'") + "');document.close()");
}
this.$refs.web.evalJs(
'var t=document.getElementsByTagName("title");t.length&&e({action:"getTitle",title:t[0].innerText});for(var o,n=document.getElementsByTagName("style"),r=0;o=n[r++];)o.innerHTML=o.innerHTML.replace(/body/g,"#parser");for(var i,a=document.getElementsByTagName("img"),s=[],c=0,l=0;i=a[c];c++)i.onerror=function(){' +
(cfg.errorImg ? 'this.src="' + cfg.errorImg + '",' : '') +
'e({action:"error",source:"img",target:this})},i.hasAttribute("ignore")||"A"==i.parentElement.nodeName||(i.i=l++,s.push(i.src),i.onclick=function(){e({action:"preview",img:{i:this.i,src:this.src}})});e({action:"getImgList",imgList:s});for(var d,u=document.getElementsByTagName("a"),g=0;d=u[g];g++)d.onclick=function(){var t,o=this.getAttribute("href");if("#"==o[0]){var n=document.getElementById(o.substr(1));n&&(t=n.offsetTop)}return e({action:"linkpress",href:o,offset:t}),!1};for(var m,f=document.getElementsByTagName("video"),h=0;m=f[h];h++)m.style.maxWidth="100%",m.onerror=function(){e({action:"error",source:"video",target:this})}' +
(this.autopause ? ',m.onplay=function(){for(var e,t=0;e=f[t];t++)e!=this&&e.pause()}' : '') +
';for(var v,y=document.getElementsByTagName("audio"),_=0;v=y[_];_++)v.onerror=function(){e({action:"error",source:"audio",target:this})};' +
(this.autoscroll ? 'for(var p,w=document.getElementsByTagName("table"),T=0;p=w[T];T++){var E=document.createElement("div");E.style.overflow="scroll",p.parentNode.replaceChild(E,p),E.appendChild(p)}' : '') +
'(function(){return new Promise(function(e){var t=document.getElementById("parser"),o=t.scrollHeight,n=setInterval(function(){o==t.scrollHeight?(clearInterval(n),e(o)):o=t.scrollHeight},500)})})().then(function(t){e({action:"ready",height:t+16})})'
)
this.nodes = [1];
// #endif
// #ifdef H5 || MP-360
if (!html) {
if (this.rtf && !append) this.rtf.parentNode.removeChild(this.rtf);
return;
}
var div = document.createElement('div');
if (!append) {
if (this.rtf) this.rtf.parentNode.removeChild(this.rtf);
this.rtf = div;
} else {
if (!this.rtf) this.rtf = div;
else this.rtf.appendChild(div);
}
div.innerHTML = this._handleHtml(html, append);
for (var styles = this.rtf.getElementsByTagName('style'), i = 0, style; style = styles[i++];) {
style.innerHTML = style.innerHTML.replace(/body/g, '#rtf' + this._uid);
style.setAttribute('scoped', 'true');
}
// 懒加载
if (!this._observer && this.lazyLoad && IntersectionObserver) {
this._observer = new IntersectionObserver(changes => {
for (let item, i = 0; item = changes[i++];) {
if (item.isIntersecting) {
item.target.src = item.target.getAttribute('data-src');
item.target.removeAttribute('data-src');
this._observer.unobserve(item.target);
}
}
}, {
rootMargin: '500px 0px 500px 0px'
})
}
var _ts = this;
// 获取标题
var title = this.rtf.getElementsByTagName('title');
if (title.length && this.autosetTitle)
uni.setNavigationBarTitle({
title: title[0].innerText
})
// 图片处理
this.imgList.length = 0;
var imgs = this.rtf.getElementsByTagName('img');
for (let i = 0, j = 0, img; img = imgs[i]; i++) {
var src = img.getAttribute('src');
if (this.domain && src) {
if (src[0] == '/') {
if (src[1] == '/')
img.src = (this.domain.includes('://') ? this.domain.split('://')[0] : '') + ':' + src;
else img.src = this.domain + src;
} else if (!src.includes('://')) img.src = this.domain + '/' + src;
}
if (!img.hasAttribute('ignore') && img.parentElement.nodeName != 'A') {
img.i = j++;
_ts.imgList.push(img.src || img.getAttribute('data-src'));
img.onclick = function() {
var preview = true;
this.ignore = () => preview = false;
_ts.$emit('imgtap', this);
if (preview) {
uni.previewImage({
current: this.i,
urls: _ts.imgList
});
}
}
}
img.onerror = function() {
if (cfg.errorImg)
_ts.imgList[this.i] = this.src = cfg.errorImg;
_ts.$emit('error', {
source: 'img',
target: this
});
}
if (_ts.lazyLoad && this._observer && img.src && img.i != 0) {
img.setAttribute('data-src', img.src);
img.removeAttribute('src');
this._observer.observe(img);
}
}
// 链接处理
var links = this.rtf.getElementsByTagName('a');
for (var link of links) {
link.onclick = function() {
var jump = true,
href = this.getAttribute('href');
_ts.$emit('linkpress', {
href,
ignore: () => jump = false
});
if (jump && href) {
if (href[0] == '#') {
if (_ts.useAnchor) {
_ts.navigateTo({
id: href.substr(1)
})
}
} else if (href.indexOf('http') == 0 || href.indexOf('//') == 0)
return true;
else
uni.navigateTo({
url: href
})
}
return false;
}
}
// 视频处理
var videos = this.rtf.getElementsByTagName('video');
_ts.videoContexts = videos;
for (let video, i = 0; video = videos[i++];) {
video.style.maxWidth = '100%';
video.onerror = function() {
_ts.$emit('error', {
source: 'video',
target: this
});
}
video.onplay = function() {
if (_ts.autopause)
for (let item, i = 0; item = _ts.videoContexts[i++];)
if (item != this) item.pause();
}
}
// 音频处理
var audios = this.rtf.getElementsByTagName('audio');
for (var audio of audios)
audio.onerror = function() {
_ts.$emit('error', {
source: 'audio',
target: this
});
}
// 表格处理
if (this.autoscroll) {
var tables = this.rtf.getElementsByTagName('table');
for (var table of tables) {
let div = document.createElement('div');
div.style.overflow = 'scroll';
table.parentNode.replaceChild(div, table);
div.appendChild(table);
}
}
if (!append) this.document.appendChild(this.rtf);
this.$nextTick(() => {
this.nodes = [1];
this.$emit('load');
});
setTimeout(() => this.showAm = '', 500);
// #endif
// #ifndef APP-PLUS-NVUE
// #ifndef H5 || MP-360
var nodes;
if (!html) return this.nodes = [];
var parser = new Parser(html, this);
// 缓存读取
if (this.useCache) {
var hashVal = hash(html);
if (cache[hashVal])
nodes = cache[hashVal];
else {
nodes = parser.parse();
cache[hashVal] = nodes;
}
} else nodes = parser.parse();
this.$emit('parse', nodes);
if (append) this.nodes = this.nodes.concat(nodes);
else this.nodes = nodes;
if (nodes.length && nodes.title && this.autosetTitle)
uni.setNavigationBarTitle({
title: nodes.title
})
if (this.imgList) this.imgList.length = 0;
this.videoContexts = [];
this.$nextTick(() => {
(function f(cs) {
for (var i = cs.length; i--;) {
if (cs[i].top) {
cs[i].controls = [];
cs[i].init();
f(cs[i].$children);
}
}
})(this.$children)
this.$emit('load');
})
// #endif
var height;
clearInterval(this._timer);
this._timer = setInterval(() => {
// #ifdef H5 || MP-360
this.rect = this.rtf.getBoundingClientRect();
// #endif
// #ifndef H5 || MP-360
uni.createSelectorQuery().in(this)
.select('#_top').boundingClientRect().exec(res => {
if (!res) return;
this.rect = res[0];
// #endif
if (this.rect.height == height) {
this.$emit('ready', this.rect)
clearInterval(this._timer);
}
height = this.rect.height;
// #ifndef H5 || MP-360
});
// #endif
}, 350);
if (this.showWithAnimation && !append) this.showAm = 'animation:_show .5s';
// #endif
},
getText(ns = this.nodes) {
var txt = '';
// #ifdef APP-PLUS-NVUE
txt = this._text;
// #endif
// #ifdef H5 || MP-360
txt = this.rtf.innerText;
// #endif
// #ifndef H5 || APP-PLUS-NVUE || MP-360
for (var i = 0, n; n = ns[i++];) {
if (n.type == 'text') txt += n.text.replace(/&nbsp;/g, '\u00A0').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
else if (n.type == 'br') txt += '\n';
else {
// 块级标签前后加换行
var block = n.name == 'p' || n.name == 'div' || n.name == 'tr' || n.name == 'li' || (n.name[0] == 'h' && n.name[1] >
'0' && n.name[1] < '7');
if (block && txt && txt[txt.length - 1] != '\n') txt += '\n';
if (n.children) txt += this.getText(n.children);
if (block && txt[txt.length - 1] != '\n') txt += '\n';
else if (n.name == 'td' || n.name == 'th') txt += '\t';
}
}
// #endif
return txt;
},
navigateTo(obj) {
if (!this.useAnchor)
return obj.fail && obj.fail({
errMsg: 'Anchor is disabled'
})
// #ifdef APP-PLUS-NVUE
if (!obj.id)
weexDom.scrollToElement(this.$refs.web);
else
this.$refs.web.evalJs('var pos=document.getElementById("' + obj.id +
'");if(pos)post({action:"linkpress",href:"#",offset:pos.offsetTop+' + (obj.offset || 0) + '})');
obj.success && obj.success({
errMsg: 'pageScrollTo:ok'
});
// #endif
// #ifdef H5 || MP-360
if (!obj.id) {
window.scrollTo(0, this.rtf.offsetTop);
return obj.success && obj.success({
errMsg: 'pageScrollTo:ok'
});
}
var target = document.getElementById(obj.id);
if (!target) return obj.fail && obj.fail({
errMsg: 'Label not found'
});
obj.scrollTop = this.rtf.offsetTop + target.offsetTop + (obj.offset || 0);
uni.pageScrollTo(obj);
// #endif
// #ifndef H5 || APP-PLUS-NVUE || MP-360
var d = ' ';
// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
d = '>>>';
// #endif
uni.createSelectorQuery().in(this).select('#_top' + (obj.id ? d + '#' + obj.id + ',#_top' + d + '.' + obj.id : '')).boundingClientRect()
.selectViewport().scrollOffset().exec(res => {
if (!res || !res[0])
return obj.fail && obj.fail({
errMsg: 'Label not found'
});
obj.scrollTop = res[1].scrollTop + res[0].top + (obj.offset || 0);
// #ifdef MP-ALIPAY
obj.duration = 300;
my.
// #endif
// #ifndef MP-ALIPAY
uni.
// #endif
pageScrollTo(obj);
})
// #endif
},
getVideoContext(id) {
// #ifndef APP-PLUS-NVUE
if (!id) return this.videoContexts;
else
for (var i = this.videoContexts.length; i--;)
if (this.videoContexts[i].id == id) return this.videoContexts[i];
// #endif
},
// #ifdef APP-PLUS-NVUE
_message(e) {
// 接收 web-view 消息
var data = e.detail.data[0];
if (data.action == 'load') {
this.$emit('load');
this.height = data.height;
this._text = data.text;
} else if (data.action == 'getTitle') {
if (this.autosetTitle)
uni.setNavigationBarTitle({
title: data.title
})
} else if (data.action == 'getImgList') {
this.imgList.length = 0;
for (var i = data.imgList.length; i--;)
this.imgList.setItem(i, data.imgList[i]);
} else if (data.action == 'preview') {
var preview = true;
data.img.ignore = () => preview = false;
this.$emit('imgtap', data.img);
if (preview)
uni.previewImage({
current: data.img.i,
urls: this.imgList
})
} else if (data.action == 'linkpress') {
var jump = true,
href = data.href;
this.$emit('linkpress', {
href,
ignore: () => jump = false
})
if (jump && href) {
if (href[0] == '#') {
if (this.useAnchor)
weexDom.scrollToElement(this.$refs.web, {
offset: data.offset
})
} else if (href.includes('://'))
plus.runtime.openWeb(href);
else
uni.navigateTo({
url: href
})
}
} else if (data.action == 'error') {
if (data.source == 'img' && cfg.errorImg)
this.imgList.setItem(data.target.i, cfg.errorImg);
this.$emit('error', {
source: data.source,
target: data.target
})
} else if (data.action == 'ready') {
this.height = data.height;
this.$nextTick(() => {
uni.createSelectorQuery().in(this).select('#_top').boundingClientRect().exec(res => {
this.rect = res[0];
this.$emit('ready', res[0]);
})
})
}
},
// #endif
}
}
</script>
<style>
@keyframes _show {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* #ifdef MP-WEIXIN */
:host {
display: block;
/* overflow: scroll; */
-webkit-overflow-scrolling: touch;
}
/* #endif */
</style>
@@ -0,0 +1,97 @@
const cfg = require('./config.js'),
isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
function CssHandler(tagStyle) {
var styles = Object.assign(Object.create(null), cfg.userAgentStyles);
for (var item in tagStyle)
styles[item] = (styles[item] ? styles[item] + ';' : '') + tagStyle[item];
this.styles = styles;
}
CssHandler.prototype.getStyle = function(data) {
this.styles = new parser(data, this.styles).parse();
}
CssHandler.prototype.match = function(name, attrs) {
var tmp, matched = (tmp = this.styles[name]) ? tmp + ';' : '';
if (attrs.class) {
var items = attrs.class.split(' ');
for (var i = 0, item; item = items[i]; i++)
if (tmp = this.styles['.' + item])
matched += tmp + ';';
}
if (tmp = this.styles['#' + attrs.id])
matched += tmp + ';';
return matched;
}
module.exports = CssHandler;
function parser(data, init) {
this.data = data;
this.floor = 0;
this.i = 0;
this.list = [];
this.res = init;
this.state = this.Space;
}
parser.prototype.parse = function() {
for (var c; c = this.data[this.i]; this.i++)
this.state(c);
return this.res;
}
parser.prototype.section = function() {
return this.data.substring(this.start, this.i);
}
// 状态机
parser.prototype.Space = function(c) {
if (c == '.' || c == '#' || isLetter(c)) {
this.start = this.i;
this.state = this.Name;
} else if (c == '/' && this.data[this.i + 1] == '*')
this.Comment();
else if (!cfg.blankChar[c] && c != ';')
this.state = this.Ignore;
}
parser.prototype.Comment = function() {
this.i = this.data.indexOf('*/', this.i) + 1;
if (!this.i) this.i = this.data.length;
this.state = this.Space;
}
parser.prototype.Ignore = function(c) {
if (c == '{') this.floor++;
else if (c == '}' && !--this.floor) this.state = this.Space;
}
parser.prototype.Name = function(c) {
if (cfg.blankChar[c]) {
this.list.push(this.section());
this.state = this.NameSpace;
} else if (c == '{') {
this.list.push(this.section());
this.Content();
} else if (c == ',') {
this.list.push(this.section());
this.Comma();
} else if (!isLetter(c) && (c < '0' || c > '9') && c != '-' && c != '_')
this.state = this.Ignore;
}
parser.prototype.NameSpace = function(c) {
if (c == '{') this.Content();
else if (c == ',') this.Comma();
else if (!cfg.blankChar[c]) this.state = this.Ignore;
}
parser.prototype.Comma = function() {
while (cfg.blankChar[this.data[++this.i]]);
if (this.data[this.i] == '{') this.Content();
else {
this.start = this.i--;
this.state = this.Name;
}
}
parser.prototype.Content = function() {
this.start = ++this.i;
if ((this.i = this.data.indexOf('}', this.i)) == -1) this.i = this.data.length;
var content = this.section();
for (var i = 0, item; item = this.list[i++];)
if (this.res[item]) this.res[item] += ';' + content;
else this.res[item] = content;
this.list = [];
this.state = this.Space;
}
@@ -0,0 +1,534 @@
/**
* html 解析器
* @tutorial https://github.com/jin-yufeng/Parser
* @version 20200615
* @author JinYufeng
* @listens MIT
*/
const cfg = require('./config.js'),
blankChar = cfg.blankChar,
CssHandler = require('./CssHandler.js'),
windowWidth = uni.getSystemInfoSync().windowWidth;
var emoji;
function MpHtmlParser(data, options = {}) {
this.attrs = {};
this.CssHandler = new CssHandler(options.tagStyle, windowWidth);
this.data = data;
this.domain = options.domain;
this.DOM = [];
this.i = this.start = this.audioNum = this.imgNum = this.videoNum = 0;
options.prot = (this.domain || '').includes('://') ? this.domain.split('://')[0] : 'http';
this.options = options;
this.state = this.Text;
this.STACK = [];
// 工具函数
this.bubble = () => {
for (var i = this.STACK.length, item; item = this.STACK[--i];) {
if (cfg.richOnlyTags[item.name]) {
if (item.name == 'table' && !Object.hasOwnProperty.call(item, 'c')) item.c = 1;
return false;
}
item.c = 1;
}
return true;
}
this.decode = (val, amp) => {
var i = -1,
j, en;
while (1) {
if ((i = val.indexOf('&', i + 1)) == -1) break;
if ((j = val.indexOf(';', i + 2)) == -1) break;
if (val[i + 1] == '#') {
en = parseInt((val[i + 2] == 'x' ? '0' : '') + val.substring(i + 2, j));
if (!isNaN(en)) val = val.substr(0, i) + String.fromCharCode(en) + val.substr(j + 1);
} else {
en = val.substring(i + 1, j);
if (cfg.entities[en] || en == amp)
val = val.substr(0, i) + (cfg.entities[en] || '&') + val.substr(j + 1);
}
}
return val;
}
this.getUrl = url => {
if (url[0] == '/') {
if (url[1] == '/') url = this.options.prot + ':' + url;
else if (this.domain) url = this.domain + url;
} else if (this.domain && url.indexOf('data:') != 0 && !url.includes('://'))
url = this.domain + '/' + url;
return url;
}
this.isClose = () => this.data[this.i] == '>' || (this.data[this.i] == '/' && this.data[this.i + 1] == '>');
this.section = () => this.data.substring(this.start, this.i);
this.parent = () => this.STACK[this.STACK.length - 1];
this.siblings = () => this.STACK.length ? this.parent().children : this.DOM;
}
MpHtmlParser.prototype.parse = function() {
if (emoji) this.data = emoji.parseEmoji(this.data);
for (var c; c = this.data[this.i]; this.i++)
this.state(c);
if (this.state == this.Text) this.setText();
while (this.STACK.length) this.popNode(this.STACK.pop());
return this.DOM;
}
// 设置属性
MpHtmlParser.prototype.setAttr = function() {
var name = this.attrName.toLowerCase(),
val = this.attrVal;
if (cfg.boolAttrs[name]) this.attrs[name] = 'T';
else if (val) {
if (name == 'src' || (name == 'data-src' && !this.attrs.src)) this.attrs.src = this.getUrl(this.decode(val, 'amp'));
else if (name == 'href' || name == 'style') this.attrs[name] = this.decode(val, 'amp');
else if (name.substr(0, 5) != 'data-') this.attrs[name] = val;
}
this.attrVal = '';
while (blankChar[this.data[this.i]]) this.i++;
if (this.isClose()) this.setNode();
else {
this.start = this.i;
this.state = this.AttrName;
}
}
// 设置文本节点
MpHtmlParser.prototype.setText = function() {
var back, text = this.section();
if (!text) return;
text = (cfg.onText && cfg.onText(text, () => back = true)) || text;
if (back) {
this.data = this.data.substr(0, this.start) + text + this.data.substr(this.i);
let j = this.start + text.length;
for (this.i = this.start; this.i < j; this.i++) this.state(this.data[this.i]);
return;
}
if (!this.pre) {
// 合并空白符
var tmp = [];
for (let i = text.length, c; c = text[--i];)
if (!blankChar[c] || (!blankChar[tmp[0]] && (c = ' '))) tmp.unshift(c);
text = tmp.join('');
}
this.siblings().push({
type: 'text',
text: this.decode(text)
});
}
// 设置元素节点
MpHtmlParser.prototype.setNode = function() {
var node = {
name: this.tagName.toLowerCase(),
attrs: this.attrs
},
close = cfg.selfClosingTags[node.name];
this.attrs = {};
if (!cfg.ignoreTags[node.name]) {
// 处理属性
var attrs = node.attrs,
style = this.CssHandler.match(node.name, attrs, node) + (attrs.style || ''),
styleObj = {};
if (attrs.id) {
if (this.options.compress & 1) attrs.id = void 0;
else if (this.options.useAnchor) this.bubble();
}
if ((this.options.compress & 2) && attrs.class) attrs.class = void 0;
switch (node.name) {
case 'a':
case 'ad': // #ifdef APP-PLUS
case 'iframe':
// #endif
this.bubble();
break;
case 'font':
if (attrs.color) {
styleObj['color'] = attrs.color;
attrs.color = void 0;
}
if (attrs.face) {
styleObj['font-family'] = attrs.face;
attrs.face = void 0;
}
if (attrs.size) {
var size = parseInt(attrs.size);
if (size < 1) size = 1;
else if (size > 7) size = 7;
var map = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
styleObj['font-size'] = map[size - 1];
attrs.size = void 0;
}
break;
case 'embed':
// #ifndef APP-PLUS
var src = node.attrs.src || '',
type = node.attrs.type || '';
if (type.includes('video') || src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8'))
node.name = 'video';
else if (type.includes('audio') || src.includes('.m4a') || src.includes('.wav') || src.includes('.mp3') || src.includes(
'.aac'))
node.name = 'audio';
else break;
if (node.attrs.autostart)
node.attrs.autoplay = 'T';
node.attrs.controls = 'T';
// #endif
// #ifdef APP-PLUS
this.bubble();
break;
// #endif
case 'video':
case 'audio':
if (!attrs.id) attrs.id = node.name + (++this[`${node.name}Num`]);
else this[`${node.name}Num`]++;
if (node.name == 'video') {
if (this.videoNum > 3)
node.lazyLoad = 1;
if (attrs.width) {
styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px');
attrs.width = void 0;
}
if (attrs.height) {
styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px');
attrs.height = void 0;
}
}
attrs.source = [];
if (attrs.src) {
attrs.source.push(attrs.src);
attrs.src = void 0;
}
this.bubble();
break;
case 'td':
case 'th':
if (attrs.colspan || attrs.rowspan)
for (var k = this.STACK.length, item; item = this.STACK[--k];)
if (item.name == 'table') {
item.c = void 0;
break;
}
}
if (attrs.align) {
styleObj['text-align'] = attrs.align;
attrs.align = void 0;
}
// 压缩 style
var styles = style.split(';');
style = '';
for (var i = 0, len = styles.length; i < len; i++) {
var info = styles[i].split(':');
if (info.length < 2) continue;
let key = info[0].trim().toLowerCase(),
value = info.slice(1).join(':').trim();
if (value.includes('-webkit') || value.includes('-moz') || value.includes('-ms') || value.includes('-o') || value.includes(
'safe'))
style += `;${key}:${value}`;
else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import'))
styleObj[key] = value;
}
if (node.name == 'img') {
if (attrs.src && !attrs.ignore) {
if (this.bubble())
attrs.i = (this.imgNum++).toString();
else attrs.ignore = 'T';
}
if (attrs.ignore) {
style += ';-webkit-touch-callout:none';
styleObj['max-width'] = '100%';
}
var width;
if (styleObj.width) width = styleObj.width;
else if (attrs.width) width = attrs.width.includes('%') ? attrs.width : attrs.width + 'px';
if (width) {
styleObj.width = width;
attrs.width = '100%';
if (parseInt(width) > windowWidth) {
styleObj.height = '';
if (attrs.height) attrs.height = void 0;
}
}
if (styleObj.height) {
attrs.height = styleObj.height;
styleObj.height = '';
} else if (attrs.height && !attrs.height.includes('%'))
attrs.height += 'px';
}
for (var key in styleObj) {
var value = styleObj[key];
if (!value) continue;
if (key.includes('flex') || key == 'order' || key == 'self-align') node.c = 1;
// 填充链接
if (value.includes('url')) {
var j = value.indexOf('(');
if (j++ != -1) {
while (value[j] == '"' || value[j] == "'" || blankChar[value[j]]) j++;
value = value.substr(0, j) + this.getUrl(value.substr(j));
}
}
// 转换 rpx
else if (value.includes('rpx'))
value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px');
else if (key == 'white-space' && value.includes('pre'))
this.pre = node.pre = true;
style += `;${key}:${value}`;
}
style = style.substr(1);
if (style) attrs.style = style;
if (!close) {
node.children = [];
if (node.name == 'pre' && cfg.highlight) {
this.remove(node);
this.pre = node.pre = true;
}
this.siblings().push(node);
this.STACK.push(node);
} else if (!cfg.filter || cfg.filter(node, this) != false)
this.siblings().push(node);
} else {
if (!close) this.remove(node);
else if (node.name == 'source') {
var parent = this.parent();
if (parent && (parent.name == 'video' || parent.name == 'audio') && node.attrs.src)
parent.attrs.source.push(node.attrs.src);
} else if (node.name == 'base' && !this.domain) this.domain = node.attrs.href;
}
if (this.data[this.i] == '/') this.i++;
this.start = this.i + 1;
this.state = this.Text;
}
// 移除标签
MpHtmlParser.prototype.remove = function(node) {
var name = node.name,
j = this.i;
// 处理 svg
var handleSvg = () => {
var src = this.data.substring(j, this.i + 1);
if (!node.attrs.xmlns) src = ' xmlns="http://www.w3.org/2000/svg"' + src;
var i = j;
while (this.data[j] != '<') j--;
src = this.data.substring(j, i) + src;
var parent = this.parent();
if (node.attrs.width == '100%' && parent && (parent.attrs.style || '').includes('inline'))
parent.attrs.style = 'width:300px;max-width:100%;' + parent.attrs.style;
this.siblings().push({
name: 'img',
attrs: {
src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
style: (/vertical[^;]+/.exec(node.attrs.style) || []).shift(),
ignore: 'T'
}
})
}
if (node.name == 'svg' && this.data[j] == '/') return handleSvg(this.i++);
while (1) {
if ((this.i = this.data.indexOf('</', this.i + 1)) == -1) {
if (name == 'pre' || name == 'svg') this.i = j;
else this.i = this.data.length;
return;
}
this.start = (this.i += 2);
while (!blankChar[this.data[this.i]] && !this.isClose()) this.i++;
if (this.section().toLowerCase() == name) {
// 代码块高亮
if (name == 'pre') {
this.data = this.data.substr(0, j + 1) + cfg.highlight(this.data.substring(j + 1, this.i - 5), node.attrs) + this.data
.substr(this.i - 5);
return this.i = j;
} else if (name == 'style')
this.CssHandler.getStyle(this.data.substring(j + 1, this.i - 7));
else if (name == 'title')
this.DOM.title = this.data.substring(j + 1, this.i - 7);
if ((this.i = this.data.indexOf('>', this.i)) == -1) this.i = this.data.length;
if (name == 'svg') handleSvg();
return;
}
}
}
// 节点出栈处理
MpHtmlParser.prototype.popNode = function(node) {
// 空白符处理
if (node.pre) {
node.pre = this.pre = void 0;
for (let i = this.STACK.length; i--;)
if (this.STACK[i].pre)
this.pre = true;
}
var siblings = this.siblings(),
len = siblings.length,
childs = node.children;
if (node.name == 'head' || (cfg.filter && cfg.filter(node, this) == false))
return siblings.pop();
var attrs = node.attrs;
// 替换一些标签名
if (cfg.blockTags[node.name]) node.name = 'div';
else if (!cfg.trustTags[node.name]) node.name = 'span';
// 去除块标签前后空串
if (node.name == 'div' || node.name == 'p' || node.name[0] == 't') {
if (len > 1 && siblings[len - 2].text == ' ')
siblings.splice(--len - 1, 1);
if (childs.length && childs[childs.length - 1].text == ' ')
childs.pop();
}
// 处理列表
if (node.c && (node.name == 'ul' || node.name == 'ol')) {
if ((node.attrs.style || '').includes('list-style:none')) {
for (let i = 0, child; child = childs[i++];)
if (child.name == 'li')
child.name = 'div';
} else if (node.name == 'ul') {
var floor = 1;
for (let i = this.STACK.length; i--;)
if (this.STACK[i].name == 'ul') floor++;
if (floor != 1)
for (let i = childs.length; i--;)
childs[i].floor = floor;
} else {
for (let i = 0, num = 1, child; child = childs[i++];)
if (child.name == 'li') {
child.type = 'ol';
child.num = ((num, type) => {
if (type == 'a') return String.fromCharCode(97 + (num - 1) % 26);
if (type == 'A') return String.fromCharCode(65 + (num - 1) % 26);
if (type == 'i' || type == 'I') {
num = (num - 1) % 99 + 1;
var one = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
ten = ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
res = (ten[Math.floor(num / 10) - 1] || '') + (one[num % 10 - 1] || '');
if (type == 'i') return res.toLowerCase();
return res;
}
return num;
})(num++, attrs.type) + '.';
}
}
}
// 处理表格的边框
if (node.name == 'table') {
var padding = attrs.cellpadding,
spacing = attrs.cellspacing,
border = attrs.border;
if (node.c) {
this.bubble();
attrs.style = (attrs.style || '') + ';display:table';
if (!padding) padding = 2;
if (!spacing) spacing = 2;
}
if (border) attrs.style = `border:${border}px solid gray;${attrs.style || ''}`;
if (spacing) attrs.style = `border-spacing:${spacing}px;${attrs.style || ''}`;
if (border || padding || node.c)
(function f(ns) {
for (var i = 0, n; n = ns[i]; i++) {
if (n.type == 'text') continue;
var style = n.attrs.style || '';
if (node.c && n.name[0] == 't') {
n.c = 1;
style += ';display:table-' + (n.name == 'th' || n.name == 'td' ? 'cell' : (n.name == 'tr' ? 'row' : 'row-group'));
}
if (n.name == 'th' || n.name == 'td') {
if (border) style = `border:${border}px solid gray;${style}`;
if (padding) style = `padding:${padding}px;${style}`;
} else f(n.children || []);
if (style) n.attrs.style = style;
}
})(childs)
if (this.options.autoscroll) {
var table = Object.assign({}, node);
node.name = 'div';
node.attrs = {
style: 'overflow:scroll'
}
node.children = [table];
}
}
this.CssHandler.pop && this.CssHandler.pop(node);
// 自动压缩
if (node.name == 'div' && !Object.keys(attrs).length && childs.length == 1 && childs[0].name == 'div')
siblings[len - 1] = childs[0];
}
// 状态机
MpHtmlParser.prototype.Text = function(c) {
if (c == '<') {
var next = this.data[this.i + 1],
isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
if (isLetter(next)) {
this.setText();
this.start = this.i + 1;
this.state = this.TagName;
} else if (next == '/') {
this.setText();
if (isLetter(this.data[++this.i + 1])) {
this.start = this.i + 1;
this.state = this.EndTag;
} else this.Comment();
} else if (next == '!') {
this.setText();
this.Comment();
}
}
}
MpHtmlParser.prototype.Comment = function() {
var key;
if (this.data.substring(this.i + 2, this.i + 4) == '--') key = '-->';
else if (this.data.substring(this.i + 2, this.i + 9) == '[CDATA[') key = ']]>';
else key = '>';
if ((this.i = this.data.indexOf(key, this.i + 2)) == -1) this.i = this.data.length;
else this.i += key.length - 1;
this.start = this.i + 1;
this.state = this.Text;
}
MpHtmlParser.prototype.TagName = function(c) {
if (blankChar[c]) {
this.tagName = this.section();
while (blankChar[this.data[this.i]]) this.i++;
if (this.isClose()) this.setNode();
else {
this.start = this.i;
this.state = this.AttrName;
}
} else if (this.isClose()) {
this.tagName = this.section();
this.setNode();
}
}
MpHtmlParser.prototype.AttrName = function(c) {
if (c == '=' || blankChar[c] || this.isClose()) {
this.attrName = this.section();
if (blankChar[c])
while (blankChar[this.data[++this.i]]);
if (this.data[this.i] == '=') {
while (blankChar[this.data[++this.i]]);
this.start = this.i--;
this.state = this.AttrValue;
} else this.setAttr();
}
}
MpHtmlParser.prototype.AttrValue = function(c) {
if (c == '"' || c == "'") {
this.start++;
if ((this.i = this.data.indexOf(c, this.i + 1)) == -1) return this.i = this.data.length;
this.attrVal = this.section();
this.i++;
} else {
for (; !blankChar[this.data[this.i]] && !this.isClose(); this.i++);
this.attrVal = this.section();
}
this.setAttr();
}
MpHtmlParser.prototype.EndTag = function(c) {
if (blankChar[c] || c == '>' || c == '/') {
var name = this.section().toLowerCase();
for (var i = this.STACK.length; i--;)
if (this.STACK[i].name == name) break;
if (i != -1) {
var node;
while ((node = this.STACK.pop()).name != name) this.popNode(node);
this.popNode(node);
} else if (name == 'p' || name == 'br')
this.siblings().push({
name,
attrs: {}
});
this.i = this.data.indexOf('>', this.i);
this.start = this.i + 1;
if (this.i == -1) this.i = this.data.length;
else this.state = this.Text;
}
}
module.exports = MpHtmlParser;
@@ -0,0 +1,93 @@
/* 配置文件 */
// #ifdef MP-WEIXIN
const canIUse = wx.canIUse('editor'); // 高基础库标识,用于兼容
// #endif
module.exports = {
// 出错占位图
errorImg: null,
// 过滤器函数
filter: null,
// 代码高亮函数
highlight: null,
// 文本处理函数
onText: null,
// 实体编码列表
entities: {
quot: '"',
apos: "'",
semi: ';',
nbsp: '\xA0',
ensp: '\u2002',
emsp: '\u2003',
ndash: '',
mdash: '—',
middot: '·',
lsquo: '',
rsquo: '',
ldquo: '“',
rdquo: '”',
bull: '•',
hellip: '…'
},
blankChar: makeMap(' ,\xA0,\t,\r,\n,\f'),
boolAttrs: makeMap('allowfullscreen,autoplay,autostart,controls,ignore,loop,muted'),
// 块级标签,将被转为 div
blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,section' + (
// #ifdef MP-WEIXIN
canIUse ? '' :
// #endif
',pre')),
// 将被移除的标签
ignoreTags: makeMap(
'area,base,canvas,frame,input,link,map,meta,param,script,source,style,svg,textarea,title,track,wbr'
// #ifdef MP-WEIXIN
+ (canIUse ? ',rp' : '')
// #endif
// #ifndef APP-PLUS
+ ',iframe'
// #endif
),
// 只能被 rich-text 显示的标签
richOnlyTags: makeMap('a,colgroup,fieldset,legend,table'
// #ifdef MP-WEIXIN
+ (canIUse ? ',bdi,bdo,caption,rt,ruby' : '')
// #endif
),
// 自闭合的标签
selfClosingTags: makeMap(
'area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'
),
// 信任的标签
trustTags: makeMap(
'a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'
// #ifdef MP-WEIXIN
+ (canIUse ? ',bdi,bdo,caption,pre,rt,ruby' : '')
// #endif
// #ifdef APP-PLUS
+ ',embed,iframe'
// #endif
),
// 默认的标签样式
userAgentStyles: {
address: 'font-style:italic',
big: 'display:inline;font-size:1.2em',
blockquote: 'background-color:#f6f6f6;border-left:3px solid #dbdbdb;color:#6c6c6c;padding:5px 0 5px 10px',
caption: 'display:table-caption;text-align:center',
center: 'text-align:center',
cite: 'font-style:italic',
dd: 'margin-left:40px',
mark: 'background-color:yellow',
pre: 'font-family:monospace;white-space:pre;overflow:scroll',
s: 'text-decoration:line-through',
small: 'display:inline;font-size:0.8em',
u: 'text-decoration:underline'
}
}
function makeMap(str) {
var map = Object.create(null),
list = str.split(',');
for (var i = list.length; i--;)
map[list[i]] = true;
return map;
}
@@ -0,0 +1,20 @@
var inline = {
abbr: 1,
b: 1,
big: 1,
code: 1,
del: 1,
em: 1,
i: 1,
ins: 1,
label: 1,
q: 1,
small: 1,
span: 1,
strong: 1
}
module.exports = {
use: function(item) {
return !item.c && !inline[item.name] && (item.attrs.style || '').indexOf('display:inline') == -1
}
}
@@ -0,0 +1,519 @@
<template>
<view class="interlayer">
<block v-for="(n, i) in nodes" v-bind:key="i">
<!--图片-->
<view v-if="n.name=='img'" :class="'_img '+n.attrs.class" :style="n.attrs.style" :data-attrs="n.attrs" @tap="imgtap">
<rich-text v-if="controls[i]!=0" :nodes="[{attrs:{src:loading&&(controls[i]||0)<2?loading:(lazyLoad&&!controls[i]?placeholder:(controls[i]==3?errorImg:n.attrs.src||'')),alt:n.attrs.alt||'',width:n.attrs.width||'',style:'-webkit-touch-callout:none;max-width:100%;display:block'+(n.attrs.height?';height:'+n.attrs.height:'')},name:'img'}]" />
<image class="_image" :src="lazyLoad&&!controls[i]?placeholder:n.attrs.src" :lazy-load="lazyLoad"
:show-menu-by-longpress="!n.attrs.ignore" :data-i="i" :data-index="n.attrs.i" data-source="img" @load="loadImg"
@error="error" />
</view>
<!--文本-->
<text v-else-if="n.type=='text'" decode>{{n.text}}</text>
<!--#ifndef MP-BAIDU-->
<text v-else-if="n.name=='br'">\n</text>
<!--#endif-->
<!--视频-->
<view v-else-if="((n.lazyLoad&&!n.attrs.autoplay)||(n.name=='video'&&!loadVideo))&&controls[i]==undefined" :id="n.attrs.id" :class="'_video '+(n.attrs.class||'')"
:style="n.attrs.style" :data-i="i" @tap="_loadVideo" />
<video v-else-if="n.name=='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay||controls[i]==0"
:controls="!n.attrs.autoplay||n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :poster="n.attrs.poster" :src="n.attrs.source[controls[i]||0]"
:unit-id="n.attrs['unit-id']" :data-id="n.attrs.id" :data-i="i" data-source="video" @error="error" @play="play" />
<!--音频-->
<audio v-else-if="n.name=='audio'" :ref="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author"
:autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster"
:src="n.attrs.source[controls[i]||0]" :data-i="i" :data-id="n.attrs.id" data-source="audio"
@error.native="error" @play.native="play" />
<!--链接-->
<view v-else-if="n.name=='a'" :id="n.attrs.id" :class="'_a '+(n.attrs.class||'')" hover-class="_hover" :style="n.attrs.style"
:data-attrs="n.attrs" @tap="linkpress">
<trees class="_span" :nodes="n.children" />
</view>
<!--广告-->
<!--<ad v-else-if="n.name=='ad'" :class="n.attrs.class" :style="n.attrs.style" :unit-id="n.attrs['unit-id']" :appid="n.attrs.appid" :apid="n.attrs.apid" :type="n.attrs.type" :adpid="n.attrs.adpid" data-source="ad" @error="error" />-->
<!--列表-->
<view v-else-if="n.name=='li'" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:flex'">
<view v-if="n.type=='ol'" class="_ol-bef">{{n.num}}</view>
<view v-else class="_ul-bef">
<view v-if="n.floor%3==0" class="_ul-p1"></view>
<view v-else-if="n.floor%3==2" class="_ul-p2" />
<view v-else class="_ul-p1" style="border-radius:50%"></view>
</view>
<!--#ifdef MP-ALIPAY-->
<view class="_li">
<trees :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
</view>
<!--#endif-->
<!--#ifndef MP-ALIPAY-->
<trees class="_li" :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
<!--#endif-->
</view>
<!--表格-->
<view v-else-if="n.name=='table'&&n.c" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:table'">
<view v-for="(tbody, o) in n.children" v-bind:key="o" :class="tbody.attrs.class" :style="(tbody.attrs.style||'')+(tbody.name[0]=='t'?';display:table-'+(tbody.name=='tr'?'row':'row-group'):'')">
<view v-for="(tr, p) in tbody.children" v-bind:key="p" :class="tr.attrs.class" :style="(tr.attrs.style||'')+(tr.name[0]=='t'?';display:table-'+(tr.name=='tr'?'row':'cell'):'')">
<trees v-if="tr.name=='td'" :nodes="tr.children" />
<block v-else>
<!--#ifdef MP-ALIPAY-->
<view v-for="(td, q) in tr.children" v-bind:key="q" :class="td.attrs.class" :style="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')">
<trees :nodes="td.children" />
</view>
<!--#endif-->
<!--#ifndef MP-ALIPAY-->
<trees v-for="(td, q) in tr.children" v-bind:key="q" :class="td.attrs.class" :style="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')"
:nodes="td.children" />
<!--#endif-->
</block>
</view>
</view>
</view>
<!--#ifdef APP-PLUS-->
<iframe v-else-if="n.name=='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder"
:width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
<embed v-else-if="n.name=='embed'" :style="n.attrs.style" :width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
<!--#endif-->
<!--富文本-->
<!--#ifdef MP-WEIXIN || MP-QQ || APP-PLUS-->
<rich-text v-else-if="handler.use(n)" :id="n.attrs.id" :class="'_p __'+n.name" :nodes="[n]" />
<!--#endif-->
<!--#ifndef MP-WEIXIN || MP-QQ || APP-PLUS-->
<rich-text v-else-if="!n.c" :id="n.attrs.id" :nodes="[n]" style="display:inline" />
<!--#endif-->
<!--#ifdef MP-ALIPAY-->
<view v-else :id="n.attrs.id" :class="'_'+n.name+' '+(n.attrs.class||'')" :style="n.attrs.style">
<trees :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
</view>
<!--#endif-->
<!--#ifndef MP-ALIPAY-->
<trees v-else :class="(n.attrs.id||'')+' _'+n.name+' '+(n.attrs.class||'')" :style="n.attrs.style" :nodes="n.children"
:lazyLoad="lazyLoad" :loading="loading" />
<!--#endif-->
</block>
</view>
</template>
<script module="handler" lang="wxs" src="./handler.wxs"></script>
<script>
global.Parser = {};
import trees from './trees'
const errorImg = require('../libs/config.js').errorImg;
export default {
components: {
trees
},
name: 'trees',
data() {
return {
controls: [],
placeholder: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="300" height="225"/>',
errorImg,
loadVideo:
// #ifdef APP-PLUS
false
// #endif
// #ifndef APP-PLUS
true
// #endif
}
},
props: {
nodes: Array,
lazyLoad: Boolean,
loading: String
},
mounted() {
for (this.top = this.$parent; this.top.$options.name != 'parser'; this.top = this.top.$parent);
this.init();
},
// #ifdef APP-PLUS
beforeDestroy() {
this.observer && this.observer.disconnect();
},
// #endif
methods: {
init() {
for (var i = this.nodes.length, n; n = this.nodes[--i];) {
if (n.name == 'img') {
this.top.imgList.setItem(n.attrs.i, n.attrs.src);
// #ifdef APP-PLUS
if (this.lazyLoad && !this.observer) {
this.observer = uni.createIntersectionObserver(this).relativeToViewport({
top: 500,
bottom: 500
});
this.$nextTick(() => {
this.observer.observe('._img', res => {
if (res.intersectionRatio) {
for (var j = this.nodes.length; j--;)
if (this.nodes[j].name == 'img')
this.$set(this.controls, j, 1);
this.observer.disconnect();
}
})
})
}
// #endif
} else if (n.name == 'video' || n.name == 'audio') {
var ctx;
if (n.name == 'video') {
ctx = uni.createVideoContext(n.attrs.id
// #ifndef MP-BAIDU
, this
// #endif
);
} else if (this.$refs[n.attrs.id])
ctx = this.$refs[n.attrs.id][0];
if (ctx) {
ctx.id = n.attrs.id;
this.top.videoContexts.push(ctx);
}
}
}
// #ifdef APP-PLUS
// APP 上避免 video 错位需要延时渲染
setTimeout(() => {
this.loadVideo = true;
}, 1000)
// #endif
},
play(e) {
var contexts = this.top.videoContexts;
if (contexts.length > 1 && this.top.autopause)
for (var i = contexts.length; i--;)
if (contexts[i].id != e.currentTarget.dataset.id)
contexts[i].pause();
},
imgtap(e) {
var attrs = e.currentTarget.dataset.attrs;
if (!attrs.ignore) {
var preview = true,
data = {
id: e.target.id,
src: attrs.src,
ignore: () => preview = false
};
global.Parser.onImgtap && global.Parser.onImgtap(data);
this.top.$emit('imgtap', data);
if (preview) {
var urls = this.top.imgList,
current = urls[attrs.i] ? parseInt(attrs.i) : (urls = [attrs.src], 0);
uni.previewImage({
current,
urls
})
}
}
},
loadImg(e) {
var i = e.currentTarget.dataset.i;
if (this.lazyLoad && !this.controls[i]) {
// #ifdef QUICKAPP-WEBVIEW
this.$set(this.controls, i, 0);
this.$nextTick(function() {
// #endif
// #ifndef APP-PLUS
this.$set(this.controls, i, 1);
// #endif
// #ifdef QUICKAPP-WEBVIEW
})
// #endif
} else if (this.loading && this.controls[i] != 2) {
// #ifdef QUICKAPP-WEBVIEW
this.$set(this.controls, i, 0);
this.$nextTick(function() {
// #endif
this.$set(this.controls, i, 2);
// #ifdef QUICKAPP-WEBVIEW
})
// #endif
}
},
linkpress(e) {
var jump = true,
attrs = e.currentTarget.dataset.attrs;
attrs.ignore = () => jump = false;
global.Parser.onLinkpress && global.Parser.onLinkpress(attrs);
this.top.$emit('linkpress', attrs);
if (jump) {
// #ifdef MP
if (attrs['app-id']) {
return uni.navigateToMiniProgram({
appId: attrs['app-id'],
path: attrs.path
})
}
// #endif
if (attrs.href) {
if (attrs.href[0] == '#') {
if (this.top.useAnchor)
this.top.navigateTo({
id: attrs.href.substring(1)
})
} else if (attrs.href.indexOf('http') == 0 || attrs.href.indexOf('//') == 0) {
// #ifdef APP-PLUS
plus.runtime.openWeb(attrs.href);
// #endif
// #ifndef APP-PLUS
uni.setClipboardData({
data: attrs.href,
success: () =>
uni.showToast({
title: '链接已复制'
})
})
// #endif
} else
uni.navigateTo({
url: attrs.href,
fail() {
uni.switchTab({
url: attrs.href,
})
}
})
}
}
},
error(e) {
var target = e.currentTarget,
source = target.dataset.source,
i = target.dataset.i;
if (source == 'video' || source == 'audio') {
// 加载其他 source
var index = this.controls[i] ? this.controls[i].i + 1 : 1;
if (index < this.nodes[i].attrs.source.length)
this.$set(this.controls, i, index);
if (e.detail.__args__)
e.detail = e.detail.__args__[0];
} else if (errorImg && source == 'img') {
this.top.imgList.setItem(target.dataset.index, errorImg);
this.$set(this.controls, i, 3);
}
this.top && this.top.$emit('error', {
source,
target,
errMsg: e.detail.errMsg
});
},
_loadVideo(e) {
this.$set(this.controls, e.target.dataset.i, 0);
}
}
}
</script>
<style>
/* 在这里引入自定义样式 */
/* 链接和图片效果 */
._a {
display: inline;
padding: 1.5px 0 1.5px 0;
color: #366092;
word-break: break-all;
}
._hover {
text-decoration: underline;
opacity: 0.7;
}
._img {
position: relative;
display: inline-block;
max-width: 100%;
}
/* #ifdef MP-WEIXIN */
:host {
display: inline;
}
/* #endif */
/* #ifdef MP || QUICKAPP-WEBVIEW */
.interlayer {
display: inherit;
flex-direction: inherit;
flex-wrap: inherit;
align-content: inherit;
align-items: inherit;
justify-content: inherit;
width: 100%;
white-space: inherit;
}
/* #endif */
._b,
._strong {
font-weight: bold;
}
._blockquote,
._div,
._p,
._ol,
._ul,
._li {
display: block;
}
._code {
font-family: monospace;
}
._del {
text-decoration: line-through;
}
._em,
._i {
font-style: italic;
}
._h1 {
font-size: 2em;
}
._h2 {
font-size: 1.5em;
}
._h3 {
font-size: 1.17em;
}
._h5 {
font-size: 0.83em;
}
._h6 {
font-size: 0.67em;
}
._h1,
._h2,
._h3,
._h4,
._h5,
._h6 {
display: block;
font-weight: bold;
}
._image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
}
._ins {
text-decoration: underline;
}
._li {
flex: 1;
width: 0;
}
._ol-bef {
width: 36px;
margin-right: 5px;
text-align: right;
}
._ul-bef {
margin: 0 12px 0 23px;
line-height: normal;
}
._ol-bef,
._ul_bef {
flex: none;
user-select: none;
}
._ul-p1 {
display: inline-block;
width: 0.3em;
height: 0.3em;
overflow: hidden;
line-height: 0.3em;
}
._ul-p2 {
display: inline-block;
width: 0.23em;
height: 0.23em;
border: 0.05em solid black;
border-radius: 50%;
}
._q::before {
content: '"';
}
._q::after {
content: '"';
}
._sub {
font-size: smaller;
vertical-align: sub;
}
._sup {
font-size: smaller;
vertical-align: super;
}
/* #ifdef MP-ALIPAY || APP-PLUS || QUICKAPP-WEBVIEW*/
._abbr,
._b,
._code,
._del,
._em,
._i,
._ins,
._label,
._q,
._span,
._strong,
._sub,
._sup {
display: inline;
}
/* #endif */
/* #ifdef MP-WEIXIN || MP-QQ */
.__bdo,
.__bdi,
.__ruby,
.__rt {
display: inline-block;
}
/* #endif */
._video {
position: relative;
display: inline-block;
width: 300px;
height: 225px;
background-color: black;
}
._video::after {
position: absolute;
top: 50%;
left: 50%;
margin: -15px 0 0 -15px;
content: '';
border-color: transparent transparent transparent white;
border-style: solid;
border-width: 15px 0 15px 30px;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import Vue from 'vue'
import App from './App'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
+119
View File
@@ -0,0 +1,119 @@
{
"name" : "在线客服",
"appid" : "__UNI__4E887D0",
"description" : "FastAdmin在线客服插件uni-app",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "",
"xhdpi" : "",
"xxhdpi" : "",
"xxxhdpi" : ""
},
"ios" : {
"appstore" : "",
"ipad" : {
"app" : "",
"app@2x" : "",
"notification" : "",
"notification@2x" : "",
"proapp@2x" : "",
"settings" : "",
"settings@2x" : "",
"spotlight" : "",
"spotlight@2x" : ""
},
"iphone" : {
"app@2x" : "",
"app@3x" : "",
"notification@2x" : "",
"notification@3x" : "",
"settings@2x" : "",
"settings@3x" : "",
"spotlight@2x" : "",
"spotlight@3x" : ""
}
}
}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx4552387af63efaec",
"setting" : {
"urlCheck" : false,
"es6" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"h5" : {
"router" : {
"mode" : "hash",
"base" : "/h5/"
},
"devServer" : {
"https" : false
},
"domain" : "kefu.zhuangzhizhinengkeji.com",
"title" : "在线客服"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
{
"path" : "pages/kefu/index",
"style" : {
"navigationBarTitleText":"在线客服",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/kefu/kefu",
"style": {
"navigationBarTitleText":"链接中...",
"navigationBarTextStyle": "black"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "在线客服",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}
+20
View File
@@ -0,0 +1,20 @@
const expression = [
{'title':'[zy]', 'src':'/emoji/1.png'},{'title':'[zm]', 'src':'/emoji/2.png'},{'title':'[jy]', 'src':'/emoji/3.png'},
{'title':'[jyb]', 'src':'/emoji/4.png'},{'title':'[bx]', 'src':'/emoji/5.png'},{'title':'[kzn]', 'src':'/emoji/6.png'},
{'title':'[gg]', 'src':'/emoji/7.png'},{'title':'[ll]', 'src':'/emoji/8.png'},{'title':'[jyll]', 'src':'/emoji/9.png'},
{'title':'[o]', 'src':'/emoji/10.png'},{'title':'[yz]', 'src':'/emoji/11.png'},{'title':'[wx]', 'src':'/emoji/12.png'},
{'title':'[zyb]', 'src':'/emoji/13.png'},{'title':'[tp]', 'src':'/emoji/14.png'},{'title':'[wxb]', 'src':'/emoji/15.png'},
{'title':'[zyc]', 'src':'/emoji/16.png'},{'title':'[llb]', 'src':'/emoji/17.png'},{'title':'[xm]', 'src':'/emoji/18.png'},
{'title':'[qz]', 'src':'/emoji/19.png'},{'title':'[zmb]', 'src':'/emoji/20.png'},{'title':'[kx]', 'src':'/emoji/21.png'},
{'title':'[mm]', 'src':'/emoji/22.png'},{'title':'[bz]', 'src':'/emoji/23.png'},{'title':'[bkx]', 'src':'/emoji/24.png'},
{'title':'[mg]', 'src':'/emoji/25.png'},{'title':'[pz]', 'src':'/emoji/26.png'},{'title':'[pzb]', 'src':'/emoji/27.png'},
{'title':'[wxc]', 'src':'/emoji/28.png'},{'title':'[jyc]', 'src':'/emoji/29.png'},{'title':'[jyd]', 'src':'/emoji/30.png'},
{'title':'[dm]', 'src':'/emoji/31.png'},{'title':'[tpb]', 'src':'/emoji/32.png'},{'title':'[tpc]', 'src':'/emoji/33.png'},
{'title':'[tpd]', 'src':'/emoji/34.png'},{'title':'[ly]', 'src':'/emoji/35.png'},{'title':'[zyd]', 'src':'/emoji/36.png'},
];
export default {
baseURL: 'kefu.com', // 启动workerman服务的域名,无需填写协议和端口
https_switch: false, // 是否启用https协议(默认关,正式版必开,且需要参考文档创建wss服务)
expression
}
+39
View File
@@ -0,0 +1,39 @@
<template>
<view class="main">
<navigator url="/pages/kefu/kefu">打开客服会话窗口-游客</navigator>
<!-- <navigator url="/pages/kefu/kefu?token=b3cc58e6-b433-48a3-8dc9-8a28e4036b62">打开客服会话窗口-指定用户</navigator> -->
<!-- 可选参数用户的 `token` 固定客服ID `fixed_csr` -->
<!-- 不传递 `token` 则自动建立游客身份 -->
<!-- 后台转移过客服代表的客户不支持指定 `fixed_csr`-->
<!-- 本客服系统客户端依赖FastAdmin插件`workerman在线客服系统`无法单独使用插件介绍/官网地址https://www.fastadmin.net/store/kefu.html -->
</view>
</template>
<script>
export default {
data() {
return {
}
},
methods: {
}
}
</script>
<style>
.main{
height: 50vh;
display: flex;
justify-content: center;
align-items: center;
}
.main navigator{
border: 1px solid #F2F2F2;
padding: 20rpx;
border-radius: 12rpx;
}
</style>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>站外调用在线客服例子</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!-- 在线客服依赖以下css和js文件,请按需引入 -->
<link rel="stylesheet" type="text/css" href="http://kefu.cn/assets/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="http://kefu.cn/assets/addons/kefu/css/kefu_default.css">
<script type="text/javascript" src="http://kefu.cn/assets/libs/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="http://kefu.cn/assets/addons/kefu/js/kefu.js"></script>
<script type="text/javascript" src="http://kefu.cn/assets/libs/fastadmin-layer/dist/layer.js"></script>
<script type="text/javascript" src="http://kefu.cn/assets/libs/bootstrap/dist/js/bootstrap.min.js"></script>
</head>
<body>
<script type="text/javascript">
KeFu.initialize('kefu.cn', 'index');
// 参数一为在线客服所在网站的域名(启动Workerman服务的网站的域名)
// 参数二为模块名,站外直接填写index
// 参数三为初始化完成后的回调方法
// 参数四为指定客服,可在此处填写客服代表的后台账户id
// 若要站外调用,请于后台-》插件管理-》本插件的配置中-》跨站调用允许域名-》填写外站的域名
// 您也可以参考站内其他模块调用的可运行示例,访问路径 `http://您的域名/addons/kefu`
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
name = kefu
title = Workerman在线客服
intro = 一款基于WebSocket的在线客服插件
author = 白衣素袖
website = https://www.fastadmin.net
version = 1.0.7
state = 1
url = /addons/kefu
license = regular
licenseto = 16556
+269
View File
@@ -0,0 +1,269 @@
-- ----------------------------
-- 配置表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_config` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '变量名',
`value` text COMMENT '变量值',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服配置表';
-- ----------------------------
-- 插入配置项
-- ----------------------------
BEGIN;
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('1', 'chat_name', '在线客服');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('2', 'ecs_exit', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('3', 'send_message_key', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('4', 'new_user_tip', '您准备好体验在线客服系统了吗?');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('5', 'new_user_msg', '这是一个欢迎新用户的消息,系统为用户成功分配客服后,自动以该客服身份发送此消息~单客服的欢迎消息请于:客服管理-》客服代表管理进行设置');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('6', 'csr_distribution', '2');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('7', 'announcement', '这是一条公告!你可以在后台管理->客服管理->会话窗口中进行更换公告内容!');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('8', 'slider_images', '/assets/addons/kefu/img/slider1.jpg,/assets/addons/kefu/img/slider2.jpg');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('9', 'chat_introduces', '<p style=\"line-height: 1.6;\">\r\n <b><span style=\"font-size: 16px;\">功能简介</span></b><br>\r\n</p>\r\n<p>\r\n <b>模块化开发</b><br>\r\n 强大的一键生成功能极速简化你的开发流程,加快你的项目开发\r\n</p>\r\n<p>\r\n <b>响应式布局</b><br>\r\n 自动适配,无需要担心兼容性问题\r\n</p>\r\n<p>\r\n <b>完善的权限管理</b><br>\r\n 自由分配子级权限、一个管理员可同时属于多个组别\r\n</p>\r\n<p>\r\n <b>通用的会员和API模块</b><br>\r\n 共用同一账号体系的Web端会员中心权限验证和API接口会员权限验证\r\n</p>\r\n<p>\r\n <b>丰富的应用市场</b><br>\r\n 第三方云存储、云短信、富文本编辑器、CMS、博客、文档生成,一切均可在线安装卸载\r\n</p>\r\n<p></p>');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('10', 'auto_invitation_switch', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('11', 'auto_invitation_timing', '7');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('12', 'invite_box_img', '/assets/addons/kefu/img/invite_box_img.jpg');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('13', 'csr_admin', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('14', 'trajectory_save_cycle', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('15', 'wechat_app_id', '');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('16', 'wechat_app_secret', '');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('17', 'wechat_token', '');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('18', 'wechat_encodingkey', '');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('19', 'new_message_notice', '');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('20', 'only_first_invitation', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('21', 'new_message_shake', '3');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('22', 'only_csr_online_invitation', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('23', 'kbs_switch', '1');
INSERT IGNORE INTO `__PREFIX__kefu_config` VALUES ('24', 'input_status_display', '2');
COMMIT;
-- ----------------------------
-- 客服代表配置表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_csr_config` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '绑定管理员',
`ceiling` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '接待上限',
`reception_count` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '当前接待量',
`last_reception_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上次接待时间',
`keep_alive` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否保持在线',
`welcome_msg` text COMMENT '欢迎语',
`status` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '状态:0=离线,1=繁忙,2=离开,3=在线',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服代表(csr)配置表';
-- ----------------------------
-- 插入客服代表
-- ----------------------------
BEGIN;
INSERT IGNORE INTO `__PREFIX__kefu_csr_config` VALUES ('1', '1', '8', '1', '1567046865', '0', '欢迎访问!', '0');
COMMIT;
-- ----------------------------
-- 用户留言表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_leave_message` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '用户(KeFu用户ID)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '姓名',
`contact` varchar(50) NOT NULL DEFAULT '' COMMENT '联系方式',
`message` text COMMENT '留言内容',
`createtime` int(10) unsigned DEFAULT '0' COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='用户留言记录';
-- ----------------------------
-- 客服接待记录
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_reception_log` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`csr_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '客服代表ID',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '用户(KeFu用户ID)',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '接待时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服接待记录';
-- ----------------------------
-- 聊天记录表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_record` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`session_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '会话ID',
`sender_identity` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '发送人身份:0=客服,1=用户',
`sender_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '发送人ID',
`message_type` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '消息类型:0=富文本,1=图片,2=文件,3=系统消息,4=商品卡片,5=订单卡片',
`message` text COMMENT '消息',
`status` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '状态:0=未读,1=已读',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='聊天记录表';
-- ----------------------------
-- 会话表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_session` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '用户',
`csr_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '客服代表ID',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
`deletetime` int(10) unsigned DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服会话表';
-- ----------------------------
-- 用户轨迹表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_trajectory` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'KeFu用户',
`csr_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '客服代表',
`log_type` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '轨迹类型:0=访问,1=被邀请,2=开始对话,3=拒绝会话,4=客服添加,5=关闭页面,6=留言,7=其他',
`note` text COMMENT '轨迹详情',
`url` text COMMENT '轨迹额外数据',
`referrer` text COMMENT '来路',
`createtime` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '添加时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='用户轨迹表';
-- ----------------------------
-- 插件用户表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '对应用户ID',
`avatar` varchar(100) NOT NULL DEFAULT '' COMMENT '头像',
`nickname` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称',
`referrer` varchar(255) NOT NULL DEFAULT '' COMMENT '用户来路',
`contact` varchar(100) NOT NULL DEFAULT '' COMMENT '联系方式',
`note` varchar(255) NOT NULL DEFAULT '' COMMENT '客服备注',
`token` varchar(59) NOT NULL DEFAULT '' COMMENT 'Session标识',
`wechat_openid` varchar(28) NOT NULL DEFAULT '' COMMENT '微信openid',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服用户表';
-- ---------------------------
-- 黑名单表
-- ---------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_blacklist` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '被屏蔽人(KeFu用户ID)',
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '操作客服',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='客服黑名单表';
-- ---------------------------
-- 快捷回复表
-- ---------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_fast_reply` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属客服',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '标题',
`content` text NOT NULL COMMENT '回复内容',
`status` enum('1','0') NOT NULL DEFAULT '1' COMMENT '状态:0=关闭,1=启用',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
`deletetime` int(10) unsigned DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='快捷回复表';
-- ----------------------------
-- 插入通用快捷回复
-- ----------------------------
BEGIN;
INSERT IGNORE INTO `__PREFIX__kefu_fast_reply` VALUES ('1', '0', '打招呼', '您好,请问有什么可以帮您?', '1', '1567332795', null);
INSERT IGNORE INTO `__PREFIX__kefu_fast_reply` VALUES ('2', '0', '询问联系方式', '您可以提供下您的联系方式么?您的电话是?或者QQ,我们可以更方便的联系您!', '1', '1567338640', null);
INSERT IGNORE INTO `__PREFIX__kefu_fast_reply` VALUES ('3', '0', '提示客户等待-处理中', '请稍等片刻,我们正在为您处理!', '1', '1567338672', null);
INSERT IGNORE INTO `__PREFIX__kefu_fast_reply` VALUES ('4', '0', '提示客户等待-问', '我去问一下,您稍等片刻~', '1', '1567338693', null);
INSERT IGNORE INTO `__PREFIX__kefu_fast_reply` VALUES ('5', '0', '道别', '那好,祝您生活愉快,再见!', '1', '1567338716', null);
COMMIT;
-- ---------------------------
-- 知识库表
-- ---------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_kbs` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`questions` text COMMENT '知识点',
`match` tinyint(3) unsigned NOT NULL DEFAULT '100' COMMENT '自动回复匹配度',
`answer` text COMMENT '问题答案',
`admin_id` varchar(100) NOT NULL DEFAULT '' COMMENT '限定客服生效',
`status` enum('2','1','0') NOT NULL DEFAULT '0' COMMENT '状态:0=关闭,1=启用,2=启用为万能知识',
`weigh` int(10) NOT NULL DEFAULT '1' COMMENT '权重',
`createtime` int(10) unsigned DEFAULT NULL COMMENT '创建时间',
`deletetime` int(10) unsigned DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='知识库表';
-- ----------------------------
-- 插入知识点
-- ----------------------------
INSERT IGNORE INTO `__PREFIX__kefu_kbs` VALUES ('1', '万能知识', '100', '<p>我是来自知识库的万能知识~</p><p>我不计算匹配度,只要没有任何知识点被匹配到,且没被“限定客服生效”所限定,就会回复我了~</p><p><b><span style="font-size: 12px;">万能知识常用于限定客服才生效,若不需要请直接从知识库删除。</span></b></p>', '1', '2', '1', '1574075097', null);
INSERT IGNORE INTO `__PREFIX__kefu_kbs` VALUES ('2', '你好\r\n您好\r\n在吗\r\n在?\r\n在', '80', '<p>亲,在的呢~</p><p>这是一条来自知识库的自动回复~</p>', '', '1', '2', '1574072922', null);
-- ---------------------------
-- 窗口工具栏表
-- ---------------------------
CREATE TABLE IF NOT EXISTS `__PREFIX__kefu_toolbar` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`position` enum('frontend','backend','general') NOT NULL DEFAULT 'backend' COMMENT '工具位置:backend=后台,frontend=前台,general=通用',
`mark` varchar(20) NOT NULL DEFAULT '' COMMENT '唯一标识',
`title` varchar(20) NOT NULL DEFAULT '' COMMENT '标题',
`icon_image` varchar(200) NOT NULL DEFAULT '' COMMENT '图标',
`data_api` varchar(200) NOT NULL DEFAULT '' COMMENT '数据接口Url',
`card_url` varchar(200) NOT NULL DEFAULT '' COMMENT '消息卡片Url',
`card_frontend_url` varchar(200) NOT NULL DEFAULT '' COMMENT '消息卡片Url(uni端)',
`status` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '状态:0=隐藏,1=正常',
`deletetime` int(10) unsigned DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='窗口工具栏表';
-- ----------------------------
-- 插入预设工具
-- ----------------------------
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('6', 'general', 'expression', '发送表情', '/assets/addons/kefu/img/smiley.png', '', '', '', '1', null);
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('5', 'general', 'file', '发送文件', '/assets/addons/kefu/img/attachment.png', '', '', '', '1', null);
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('4', 'general', 'link', '发送链接', '/assets/addons/kefu/img/link.png', '', '', '', '1', null);
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('3', 'backend', 'fastreply', '快捷回复', '/assets/addons/kefu/img/fastreply.png', '', '', '', '1', null);
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('2', 'frontend', 'goods', '发送商品', '/assets/addons/kefu/img/goods.png', '/api/Kefu/goodsList', '/bISTVBHhuo.php/user/user', '', '0', null);
INSERT IGNORE INTO `__PREFIX__kefu_toolbar` VALUES ('1', 'frontend', 'order', '发送订单', '/assets/addons/kefu/img/order.png', '/api/Kefu/orderList', '/bISTVBHhuo.php/kefu/csrkpi', '', '0', null);
-- ----------------------------
-- 旧版本字段处理
-- ----------------------------
BEGIN;
ALTER TABLE `__PREFIX__kefu_blacklist` ADD COLUMN `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '操作客服' AFTER `user_id`;
COMMIT;
BEGIN;
ALTER TABLE `__PREFIX__kefu_csr_config` ADD COLUMN `keep_alive` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否保持在线' AFTER `last_reception_time`;
COMMIT;
BEGIN;
ALTER TABLE `__PREFIX__kefu_user` ADD COLUMN `referrer` varchar(255) NOT NULL DEFAULT '' COMMENT '用户来路' AFTER `nickname`;
ALTER TABLE `__PREFIX__kefu_user` ADD COLUMN `contact` varchar(100) NOT NULL DEFAULT '' COMMENT '联系方式' AFTER `referrer`;
ALTER TABLE `__PREFIX__kefu_user` ADD COLUMN `note` varchar(255) NOT NULL DEFAULT '' COMMENT '客服备注' AFTER `contact`;
COMMIT;
BEGIN;
ALTER TABLE `__PREFIX__kefu_record` MODIFY message_type tinyint(1) comment '消息类型:0=富文本,1=图片,2=文件,3=系统消息,4=商品卡片,5=订单卡片';
COMMIT;
BEGIN;
ALTER TABLE `__PREFIX__kefu_csr_config` ADD COLUMN `welcome_msg` text COMMENT '欢迎语' AFTER `keep_alive`;
COMMIT;
BEGIN;
ALTER TABLE `__PREFIX__kefu_blacklist` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_config` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_csr_config` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_fast_reply` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_kbs` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_leave_message` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_reception_log` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_record` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_session` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_toolbar` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_trajectory` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `__PREFIX__kefu_user` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
COMMIT;
+1320
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
<?php
/**
* This file is part of workerman.
*
* 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
*/
/**
* 用于检测业务代码死循环或者长时间阻塞等问题
* 如果发现业务卡死,可以将下面declare打开(去掉//注释),并执行php start.php reload
* 然后观察一段时间workerman.log看是否有process_timeout异常
*/
namespace addons\kefu\library\GatewayWorker\Applications\KeFu;
//declare(ticks=1);
use addons\kefu\library\Common;
use GatewayWorker\Lib\Gateway;
use Workerman\Lib\Timer;
use think\Db;
/**
* 主逻辑
* 主要是处理 onConnect onMessage onClose 三个方法
* onConnect 和 onClose 如果不需要可以不用实现并删除
*/
class Events
{
/**
* WebSocket 链接成功
*
* @param int $client_id data
* @param $[data] [websocket握手时的http头数据,包含get、server等变量]
*/
public static function onWebSocketConnect($client_id, $data)
{
// 安全检查
array_walk_recursive($data, ['addons\kefu\library\Common', 'checkVariable']);
$now_time = time();
$initialize_data = [];
$initialize_data['chat_name'] = Db::name('kefu_config')->where('name', 'chat_name')->value('value');
$agreement = (stripos($data['server']['HTTP_ORIGIN'], 'https://') === false) ? 'http://' : 'https://';
$_SESSION['cdn_url'] = $agreement . $data['server']['SERVER_NAME']; //设置服务器域名
$kefu_config = get_addon_config('kefu');
$upload = \app\common\model\Config::upload();
// 上传信息配置后
\think\Hook::listen("upload_config_init", $upload);
$_SESSION['cdn_url'] = $upload['cdnurl'] ? $upload['cdnurl'] : $_SESSION['cdn_url'];
// 获取连接人信息
$token_info = false;
if (!isset($data['get']['modulename'])) {
Gateway::sendToClient($client_id, json_encode([
'code' => 0,
'msgtype' => 'clear',
'msg' => $initialize_data['chat_name'] . ' 模块未知',
]));
return;
}
if ($data['get']['modulename'] == 'admin' && isset($data['get']['token'])) {
// 验证管理员身份
$token_info = Common::checkAdmin($data['get']['token']);
// 设置定时器,定时检测管理员身份是否过期
$_SESSION['auth_timer_id'] = Timer::add(30, function ($client_id, $token) {
$token_info = Common::checkAdmin($token);
if (!$token_info) {
Gateway::closeClient($client_id);
}
}, [$client_id, $data['get']['token']]);
} elseif ($data['get']['modulename'] != 'admin' && isset($data['get']['token'])) {
// 验证FA用户身份
$user_id = Common::checkFaUser($data['get']['token']);
if ($user_id) {
// 验证KeFu用户身份
$token_info = Common::checkKefuUser('', $user_id);
if ($token_info) {
// 设置定时器,定时检测用户身份是否过期
$_SESSION['auth_timer_id'] = Timer::add(60, function ($client_id, $token) {
$user_id = Common::checkFaUser($token);
if (!$user_id) {
Gateway::closeClient($client_id);
}
}, [$client_id, $data['get']['token']]);
}
}
}
if ($data['get']['modulename'] != 'admin' && isset($data['get']['kefu_tourists_token']) && !$token_info) {
// 验证KeFu用户身份
$token_info = Common::checkKefuUser($data['get']['kefu_tourists_token'], 0);
}
if ($token_info) {
if (isset($token_info['token'])) {
unset($token_info['token']);
}
if (isset($token_info['blacklist']) && $token_info['blacklist']) {
Gateway::sendToClient($client_id, json_encode([
'code' => 0,
'msgtype' => 'clear',
'msg' => $initialize_data['chat_name'] . ' 黑名单用户!',
]));
return;
}
Gateway::bindUid($client_id, $token_info['user_id']);
$_SESSION['user_id'] = $token_info['user_id'];
} else {
Gateway::sendToClient($client_id, json_encode([
'code' => 0,
'msgtype' => 'clear',
'msg' => $initialize_data['chat_name'] . ' 无法识别链接用户身份,请重新登录!',
]));
return;
}
if ($data['get']['modulename'] == 'admin') {
// 读取会话列表
$session = Db::name('kefu_session')
->alias('s')
->field('s.*,CONCAT(u.id,"||user") as session_user,u.user_id as fu_user_id,u.avatar,u.nickname,u.wechat_openid,fu.avatar as fu_avatar,fu.nickname as fu_nickname')
->join('kefu_user u', 'u.id=s.user_id')
->join('user fu', 'u.user_id=fu.id', 'LEFT')
->where('s.csr_id', $token_info['id'])
->where('s.deletetime', null)
->limit(40)
->order('s.createtime desc')
->select();
$session = array_reverse($session, false); // 会话分组时数组键将被逆转,最终给到前台的则是可以直接for in的数组
// 会话列表分组 在线的且上次消息时间在最近的-放入对话中 不在线的或者上次消息时间较久的放入最近沟通
$session_temp = [];
foreach ($session as $key => $value) {
// 最后一条聊天记录
$last_message = Db::name('kefu_record')
->where('session_id', $value['id'])
->order('createtime desc')
->find();
$value['last_message'] = Common::formatMessage($last_message);
$value['last_time'] = Common::formatSessionTime(isset($last_message['createtime']) ? $last_message['createtime'] : null);
$value['online'] = $value['wechat_openid'] ? 1 : Gateway::isUidOnline($value['session_user']);
$value['avatar'] = $value['fu_avatar'] ? $value['fu_avatar'] : $value['avatar'];
$value['nickname'] = $value['fu_nickname'] ? $value['fu_nickname'] : $value['nickname'];
$value['avatar'] = Common::imgSrcFill($value['avatar'], true);
// 用户发来的未读消息数
$value['unread_msg_count'] = Db::name('kefu_record')
->where('session_id', $value['id'])
->where('sender_identity', 1)
->where('sender_id', $value['user_id'])
->where('status', 0)
->count('id');
$last_time = isset($last_message['createtime']) ? $last_message['createtime'] : $value['createtime'];
$dialogue_time = $value['wechat_openid'] ? 600 : 43200; // 这个时间内的会话计入会话中
if ($value['online'] || ($now_time - $last_time < $dialogue_time) || $value['unread_msg_count'] > 0) {
$session_temp['dialogue'][] = $value;
} else {
$session_temp['recently'][] = $value;
}
}
// 客服上线
$reception_count = isset($session_temp['dialogue']) ? count($session_temp['dialogue']) : false;
if ($reception_count) {
Db::name('kefu_csr_config')->where('admin_id', $token_info['id'])->update([
'reception_count' => $reception_count,
]);
}
// 获取访问中(邀请中)的用户->查询对应的用户信息
$invitation = Gateway::getAllUidList();
$invitation_user_ids = [];
foreach ($invitation as $key => $value) {
$invitation_user_id = explode('||', $value);
if (isset($invitation_user_id[1]) && $invitation_user_id[1] != 'csr' && (int)$invitation_user_id[0] > 0) {
$invitation_user_ids[] = (int)$invitation_user_id[0];
}
}
$invitation_user_ids = implode(',', $invitation_user_ids);
$invitation = Db::name('kefu_user')
->alias('u')
->field('u.id,u.avatar,u.nickname,u.createtime,s.id as sid,fu.avatar as fu_avatar,fu.nickname as fu_nickname')
->join('user fu', 'u.user_id=fu.id', 'LEFT')
->join('kefu_session s', 's.user_id=u.id', 'LEFT')
->whereIn('u.id', $invitation_user_ids)
->where('s.id', null)
->select();
foreach ($invitation as $key => $value) {
/*$trajectory = Db::name('kefu_trajectory')
->where('user_id', $value['id'])
->order('id desc')
->find();*/
$invitation[$key]['id'] = 'invitation||' . $value['id'];
$invitation[$key]['avatar'] = $value['fu_avatar'] ? $value['fu_avatar'] : $value['avatar'];
$invitation[$key]['avatar'] = Common::imgSrcFill($invitation[$key]['avatar'], true);
$invitation[$key]['nickname'] = $value['fu_nickname'] ? $value['fu_nickname'] : $value['nickname'];
$invitation[$key]['online'] = 1;
$invitation[$key]['unread_msg_count'] = 0;
$invitation[$key]['last_message'] = '';
$invitation[$key]['session_user'] = $value['id'] . '||user';
$invitation[$key]['last_time'] = Common::formatSessionTime($value['createtime']);
}
$session_temp['invitation'] = $invitation;
$initialize_data['session'] = $session_temp;
// 获取状态
$token_info['status_text'] = Common::csrStatus(null);
$tourists = 'not';
} else {
if (!Db::name('kefu_session')->where('user_id', $token_info['id'])->value('id')) {
// 无客服游客-供前台建立会话
$avatar = $token_info['fu_avatar'] ? $token_info['fu_avatar'] : $token_info['avatar'];
$tourists = [
'id' => 'invitation||' . $token_info['id'],
'avatar' => Common::imgSrcFill($avatar, true),
'nickname' => $token_info['fu_nickname'] ? $token_info['fu_nickname'] : $token_info['nickname'],
'online' => 1,
'unread_msg_count' => 0,
'session_user' => $token_info['id'] . '||user',
'last_message' => '',
'last_time' => Common::formatSessionTime($token_info['createtime']),
];
} else {
$tourists = 'not';
}
}
$initialize_data['modulename'] = $data['get']['modulename'];
$initialize_data['user_info'] = $token_info;
$initialize_data['new_msg'] = Common::getUnreadMessages($_SESSION['user_id'], true);
// 向当前client_id发送数据
Gateway::sendToClient($client_id, json_encode(['msgtype' => 'initialize', 'data' => $initialize_data]));
// 向所有人发送
Gateway::sendToAll(json_encode([
'msgtype' => 'online',
'user_id' => $_SESSION['user_id'],
'user_name' => isset($token_info['fu_nickname']) ? ($token_info['fu_nickname'] . '(' . $token_info['nickname'] . ')') : $token_info['nickname'],
'tourists' => $tourists,
'modulename' => $data['get']['modulename'],
]));
}
/**
* 当客户端发来消息时触发
* @param int $client_id 连接id
* @param mixed $message 具体消息
*/
public static function onMessage($client_id, $message)
{
$chat_name = Db::name('kefu_config')->where('name', 'chat_name')->value('value');
// 分发到控制器
$data = json_decode($message, true);
// 安全检查
array_walk_recursive($data, ['addons\kefu\library\Common', 'checkVariable']);
if (!is_array($data) || !isset($data['c']) || !isset($data['a'])) {
common::showMsg($client_id, $chat_name . ' 错误的请求!');
return;
}
if ($data['c'] == 'clear') {
Gateway::closeClient($client_id);
return '';
}
$filename = __DIR__ . '/controller/' . $data['c'] . '.php'; //载入文件类似/controller/index.php
if (file_exists($filename)) {
require_once $filename;
/*
检查要访问的类是否存在
*/
if (!class_exists($data['c'], false)) {
common::showMsg($client_id, $chat_name . ' 您访问的控制器不存在!');
return;
}
} else {
common::showMsg($client_id, $chat_name . ' 您访问的文件并存在!');
return;
}
$o = new $data['c'](); // 新建对象
if (!method_exists($o, $data['a'])) {
common::showMsg($client_id, $chat_name . ' 您访问的方法并存在!');
return;
}
$data['data'] = isset($data['data']) ? $data['data'] : '';
call_user_func_array([$o, $data['a']], [$client_id, $data['data']]); //调用对象$o($c)里的方法$a
}
/**
* 当用户断开连接时触发
* @param int $client_id 连接id
*/
public static function onClose($client_id)
{
if (isset($_SESSION['auth_timer_id'])) {
Timer::del($_SESSION['auth_timer_id']);
}
// 向所有人发送
if (isset($_SESSION['user_id'])) {
// 此user_id下还有其他链接
try {
if (Gateway::getClientIdByUid($_SESSION['user_id'])) {
return;
}
} catch (\Exception $e) {
}
$user_info = Common::userInfo($_SESSION['user_id']);
if ($user_info['source'] == 'user') {
$csr_id = Db::name('kefu_session')->where('user_id', $user_info['id'])->value('csr_id');
if ($csr_id) {
$reception_count = Db::name('kefu_csr_config')
->where('admin_id', $csr_id)
->value('reception_count');
if ($reception_count > 0) {
Db::name('kefu_csr_config')->where('admin_id', $csr_id)->setDec('reception_count');
}
}
} elseif ($user_info['source'] == 'csr' && $user_info['status'] == 3) {
// 客服保持在线
$keep_alive = Db::name('kefu_csr_config')->where('admin_id', $user_info['id'])->value('keep_alive');
if ($keep_alive) {
return;
}
Common::csrStatus(0);
// 客服下线
Db::name('kefu_csr_config')->where('admin_id', $user_info['id'])->update([
'reception_count' => 0,
]);
}
Gateway::sendToAll(json_encode([
'msgtype' => 'offline',
'user_id' => $_SESSION['user_id'],
]));
}
Db::clear();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
<?php
/**
* This file is part of workerman.
*
* 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 GatewayWorker\BusinessWorker;
use Workerman\Worker;
// 自动加载类
require_once __DIR__ . '/../../vendor/autoload.php';
// 获取插件配置
$kefu_config = get_addon_config('kefu');
// bussinessWorker 进程
$worker = new BusinessWorker();
// worker名称
$worker->name = 'KeFuBusinessWorker';
// bussinessWorker进程数量
$worker->count = $kefu_config['worker_process_number'];
// 服务注册地址
$worker->registerAddress = '127.0.0.1:' . $kefu_config['register_port'];
//设置处理业务的类,此处制定Events的命名空间
$worker->eventHandler = 'addons\kefu\library\GatewayWorker\Applications\KeFu\Events';
// 如果不是在根目录启动,则运行runAll方法
if (!defined('GLOBAL_START')) {
Worker::runAll();
}
@@ -0,0 +1,71 @@
<?php
/**
* This file is part of workerman.
*
* 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 GatewayWorker\Gateway;
use Workerman\Worker;
require_once __DIR__ . '/../../vendor/autoload.php';
// gateway 进程
$kefu_config = get_addon_config('kefu');
$context = [];
$ssl_start = false;
if ($kefu_config['wss_switch'] && $kefu_config['ssl_cert'] && $kefu_config['ssl_cert_key']) {
$context ['ssl'] = [
// 使用绝对路径
'local_cert' => $kefu_config['ssl_cert'], // 也可以是crt文件
'local_pk' => $kefu_config['ssl_cert_key'],
'verify_peer' => false,
//'allow_self_signed' => true, //如果是自签名证书开启此选项
];
$ssl_start = true;
}
$gateway = new Gateway("websocket://0.0.0.0:" . $kefu_config['websocket_port'], $context);
if ($ssl_start) {
// 开始SSL
$gateway->transport = 'ssl';
}
// gateway名称,status方便查看
$gateway->name = 'KeFuGateway' . ($ssl_start ? '-wss' : '');
// gateway进程数
$gateway->count = $kefu_config['gateway_process_number'];
// 本机ip,分布式部署时使用内网ip
$gateway->lanIp = '127.0.0.1';
// 内部通讯起始端口,假如$gateway->count=4,起始端口为4000
// 则一般会使用4000 4001 4002 4003 4个端口作为内部通讯端口
$gateway->startPort = $kefu_config['internal_start_port'];
// 服务注册地址
$gateway->registerAddress = '127.0.0.1:' . $kefu_config['register_port'];
// 心跳间隔
$gateway->pingInterval = 30;
$gateway->pingNotResponseLimit = 1;
// 心跳数据
$gateway->pingData = '';
// 如果不是在根目录启动,则运行runAll方法
if (!defined('GLOBAL_START')) {
Worker::runAll();
}
@@ -0,0 +1,30 @@
<?php
/**
* This file is part of workerman.
*
* 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 GatewayWorker\Register;
use Workerman\Worker;
// 自动加载类
require_once __DIR__ . '/../../vendor/autoload.php';
// 获取插件配置
$kefu_config = get_addon_config('kefu');
// register 必须是text协议
$register = new Register('text://0.0.0.0:' . $kefu_config['register_port']);
// 如果不是在根目录启动,则运行runAll方法
if (!defined('GLOBAL_START')) {
Worker::runAll();
}
@@ -0,0 +1,22 @@
<?php
/*内部通信服务*/
use GatewayWorker\Gateway;
use Workerman\Autoloader;
use Workerman\Worker;
// 自动加载类
require_once __DIR__ . '/../../vendor/autoload.php';
Autoloader::setRootPath(__DIR__);
$kefu_config = get_addon_config('kefu');
$internal_gateway = new Gateway("Text://127.0.0.1:" . ($kefu_config['register_port'] + 100));
$internal_gateway->name = 'KeFuGateway';
$internal_gateway->startPort = $kefu_config['internal_start_port'] + 1000;
$internal_gateway->registerAddress = '127.0.0.1:' . $kefu_config['register_port'];// 端口为start_register.php中监听的端口
// 如果不是在根目录启动,则运行runAll方法
if (!defined('GLOBAL_START')) {
Worker::runAll();
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2009-2015 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/workerman/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.
+46
View File
@@ -0,0 +1,46 @@
GatewayWorker windows 版本
=================
GatewayWorker基于[Workerman](https://github.com/walkor/Workerman)开发的一个项目框架,用于快速开发长连接应用,例如app推送服务端、即时IM服务端、游戏服务端、物联网、智能家居等等。
GatewayWorker使用经典的Gateway和Worker进程模型。Gateway进程负责维持客户端连接,并转发客户端的数据给Worker进程处理;Worker进程负责处理实际的业务逻辑,并将结果推送给对应的客户端。Gateway服务和Worker服务可以分开部署在不同的服务器上,实现分布式集群。
GatewayWorker提供非常方便的API,可以全局广播数据、可以向某个群体广播数据、也可以向某个特定客户端推送数据。配合Workerman的定时器,也可以定时推送数据。
GatewayWorker Linux 版本
======================
Linux 版本GatewayWorker 在这里 https://github.com/walkor/GatewayWorker
启动
=======
双击start_for_win.bat
Applications\YourApp测试方法
======
使用telnet命令测试(不要使用windows自带的telnet
```shell
telnet 127.0.0.1 8282
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
Hello 3
3 login
haha
3 said haha
```
手册
=======
http://www.workerman.net/gatewaydoc/
使用GatewayWorker-for-win开发的项目
=======
## [tadpole](http://kedou.workerman.net/)
[Live demo](http://kedou.workerman.net/)
[Source code](https://github.com/walkor/workerman)
![workerman-todpole](http://www.workerman.net/img/workerman-todpole.png)
## [chat room](http://chat.workerman.net/)
[Live demo](http://chat.workerman.net/)
[Source code](https://github.com/walkor/workerman-chat)
![workerman-chat](http://www.workerman.net/img/workerman-chat.png)
+9
View File
@@ -0,0 +1,9 @@
{
"name" : "workerman/gateway-worker-demo",
"keywords": ["distributed","communication"],
"homepage": "http://www.workerman.net",
"license" : "MIT",
"require": {
"workerman/gateway-worker" : ">=3.0.0"
}
}
+111
View File
@@ -0,0 +1,111 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ab9c3e87dac1a4a30c63a47de76a8217",
"packages": [
{
"name": "workerman/gateway-worker",
"version": "v3.0.18",
"source": {
"type": "git",
"url": "https://github.com/walkor/GatewayWorker.git",
"reference": "50d3a77deb7f7fb206d641ee0307ae1c41d5d41d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/walkor/GatewayWorker/zipball/50d3a77deb7f7fb206d641ee0307ae1c41d5d41d",
"reference": "50d3a77deb7f7fb206d641ee0307ae1c41d5d41d",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"workerman/workerman": ">=3.5.0"
},
"type": "library",
"autoload": {
"psr-4": {
"GatewayWorker\\": "./src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"homepage": "http://www.workerman.net",
"keywords": [
"communication",
"distributed"
],
"time": "2020-07-15T06:45:01+00:00"
},
{
"name": "workerman/workerman",
"version": "v4.0.10",
"source": {
"type": "git",
"url": "https://github.com/walkor/Workerman.git",
"reference": "132a277b1836464c8fb99e9146ca161de8d7199f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/walkor/Workerman/zipball/132a277b1836464c8fb99e9146ca161de8d7199f",
"reference": "132a277b1836464c8fb99e9146ca161de8d7199f",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": ">=5.3"
},
"suggest": {
"ext-event": "For better performance. "
},
"type": "library",
"autoload": {
"psr-4": {
"Workerman\\": "./"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "http://www.workerman.net",
"role": "Developer"
}
],
"description": "An asynchronous event driven PHP framework for easily building fast, scalable network applications.",
"homepage": "http://www.workerman.net",
"keywords": [
"asynchronous",
"event-loop"
],
"time": "2020-09-15T09:22:45+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "1.1.0"
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* run with command
* php start.php start
*/
namespace addons\kefu\library\gatewayworker;
ini_set('display_errors', 'on');
use think\Config;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\Db;
use think\Exception;
use think\exception\PDOException;
use Workerman\Worker;
/**
*
*/
class start extends Command
{
protected function configure()
{
$this->setName('kefu')
->addArgument('action', Argument::OPTIONAL, "action start [d]|stop|restart|status")
->addArgument('type', Argument::OPTIONAL, "d -d")
->setDescription('KeFu 会话服务');
}
protected function execute(Input $input, Output $output)
{
global $argv;
$action = trim($input->getArgument('action'));
$type = trim($input->getArgument('type')) ? '-d' : '';
$argv[0] = 'chat';
$argv[1] = $action;
$argv[2] = $type ? '-d' : '';
$this->start();
}
private function start()
{
if (strpos(strtolower(PHP_OS), 'win') === 0) {
exit("Windows下不支持窗口启动,请手动运行(not support windows, please use)public/kefu_start_for_win.bat\n");
}
// 检查扩展
if (!extension_loaded('pcntl')) {
exit("Please install pcntl extension. See http://doc.workerman.net/appendices/install-extension.html\n");
}
if (!extension_loaded('posix')) {
exit("Please install posix extension. See http://doc.workerman.net/appendices/install-extension.html\n");
}
// 标记是全局启动
define('GLOBAL_START', 1);
require_once __DIR__ . '/vendor/autoload.php';
// 加载所有Applications/*/start.php,以便启动所有服务
foreach (glob(__DIR__ . '/Applications/*/start*.php') as $start_file) {
require_once $start_file;
}
// 运行所有服务
Worker::runAll();
}
}
+2
View File
@@ -0,0 +1,2 @@
php Applications\FsatChat\start_register.php Applications\FsatChat\start_gateway.php Applications\FsatChat\start_businessworker.php Applications\FsatChat\start_text_gateway.php
pause
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace addons\kefu\library;
class StrComparison
{
protected $str1;
protected $str2;
protected $c = [];
/*
* 返回串一和串二的最长公共子序列
*/
public function getLCS($str1, $str2, $len1 = 0, $len2 = 0)
{
$this->str1 = $str1;
$this->str2 = $str2;
if ($len1 == 0) {
$len1 = strlen($str1);
}
if ($len2 == 0) {
$len2 = strlen($str2);
}
$this->initC($len1, $len2);
return $this->printLCS($this->c, $len1 - 1, $len2 - 1);
}
/*
* 返回两个串的相似度
*/
public function getSimilar($str1, $str2)
{
$len1 = strlen($str1);
$len2 = strlen($str2);
$len = strlen($this->getLCS($str1, $str2, $len1, $len2));
$similar = $len * 2 / ($len1 + $len2);
return round($similar * 100, 2);
}
public function initC($len1, $len2)
{
for ($i = 0; $i < $len1; $i++) {
$this->c[$i][0] = 0;
}
for ($j = 0; $j < $len2; $j++) {
$this->c[0][$j] = 0;
}
for ($i = 1; $i < $len1; $i++) {
for ($j = 1; $j < $len2; $j++) {
if ($this->str1[$i] == $this->str2[$j]) {
$this->c[$i][$j] = $this->c[$i - 1][$j - 1] + 1;
} elseif ($this->c[$i - 1][$j] >= $this->c[$i][$j - 1]) {
$this->c[$i][$j] = $this->c[$i - 1][$j];
} else {
$this->c[$i][$j] = $this->c[$i][$j - 1];
}
}
}
}
public function printLCS($c, $i, $j)
{
if ($i == 0 || $j == 0) {
if ($this->str1[$i] == $this->str2[$j]) {
return $this->str2[$j];
} else {
return "";
}
}
if ($this->str1[$i] == $this->str2[$j]) {
return $this->printLCS($this->c, $i - 1, $j - 1) . $this->str2[$j];
} elseif ($this->c[$i - 1][$j] >= $this->c[$i][$j - 1]) {
return $this->printLCS($this->c, $i - 1, $j);
} else {
return $this->printLCS($this->c, $i, $j - 1);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace addons\kefu\library\WechatCrypto;
/**
* error code 说明.
* <ul>
* <li>-40001: 签名验证错误</li>
* <li>-40002: xml解析失败</li>
* <li>-40003: sha加密生成签名失败</li>
* <li>-40004: encodingAesKey 非法</li>
* <li>-40005: appid 校验错误</li>
* <li>-40006: aes 加密失败</li>
* <li>-40007: aes 解密失败</li>
* <li>-40008: 解密后得到的buffer非法</li>
* <li>-40009: base64加密失败</li>
* <li>-40010: base64解密失败</li>
* <li>-40011: 生成xml失败</li>
* </ul>
*/
class ErrorCode
{
public static $OK = 0;
public static $ValidateSignatureError = -40001;
public static $ParseXmlError = -40002;
public static $ComputeSignatureError = -40003;
public static $IllegalAesKey = -40004;
public static $ValidateAppidError = -40005;
public static $EncryptAESError = -40006;
public static $DecryptAESError = -40007;
public static $IllegalBuffer = -40008;
public static $EncodeBase64Error = -40009;
public static $DecodeBase64Error = -40010;
public static $GenReturnXmlError = -40011;
}
?>
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace addons\kefu\library\WechatCrypto;
use addons\kefu\library\WechatCrypto\ErrorCode;
/**
* PKCS7Encoder class
*
* 提供基于PKCS7算法的加解密接口.
*/
class PKCS7Encoder
{
public static $block_size = 32;
/**
* 对需要加密的明文进行填充补位
* @param $text 需要进行填充补位操作的明文
* @return 补齐明文字符串
*/
function encode($text)
{
$block_size = PKCS7Encoder::$block_size;
$text_length = strlen($text);
//计算需要填充的位数
$amount_to_pad = PKCS7Encoder::$block_size - ($text_length % PKCS7Encoder::$block_size);
if ($amount_to_pad == 0) {
$amount_to_pad = PKCS7Encoder::block_size;
}
//获得补位所用的字符
$pad_chr = chr($amount_to_pad);
$tmp = "";
for ($index = 0; $index < $amount_to_pad; $index++) {
$tmp .= $pad_chr;
}
return $text . $tmp;
}
/**
* 对解密后的明文进行补位删除
* @param decrypted 解密后的明文
* @return 删除填充补位后的明文
*/
function decode($text)
{
$pad = ord(substr($text, -1));
if ($pad < 1 || $pad > 32) {
$pad = 0;
}
return substr($text, 0, (strlen($text) - $pad));
}
}
?>
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace addons\kefu\library\WechatCrypto;
use addons\kefu\library\WechatCrypto\ErrorCode;
use addons\kefu\library\WechatCrypto\PKCS7Encoder;
/**
* Prpcrypt class
*
* 提供接收和推送给公众平台消息的加解密接口.
*/
class Prpcrypt
{
public $key;
function __construct($k)
{
$this->key = base64_decode($k . "=");
}
/**
* 对明文进行加密
* @param string $text 需要加密的明文
* @return string 加密后的密文
*/
public function encrypt($text, $appid)
{
try {
//获得16位随机字符串,填充到明文之前
$random = $this->getRandomStr();
$text = $random . pack("N", strlen($text)) . $text . $appid;
$iv = substr($this->key, 0, 16);
$pkc_encoder = new PKCS7Encoder;
$text = $pkc_encoder->encode($text);
$encrypted = openssl_encrypt($text, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
//print(base64_encode($encrypted));
//使用BASE64对加密后的字符串进行编码
return [ErrorCode::$OK, base64_encode($encrypted)];
} catch (Exception $e) {
//print $e;
return [ErrorCode::$EncryptAESError, null];
}
}
/**
* 随机生成16位字符串
* @return string 生成的字符串
*/
function getRandomStr()
{
$str = "";
$str_pol = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
$max = strlen($str_pol) - 1;
for ($i = 0; $i < 16; $i++) {
$str .= $str_pol[mt_rand(0, $max)];
}
return $str;
}
/**
* 对密文进行解密
* @param string $encrypted 需要解密的密文
* @return string 解密得到的明文
*/
public function decrypt($encrypted, $appid)
{
try {
//使用BASE64对需要解密的字符串进行解码
$ciphertext_dec = base64_decode($encrypted);
$iv = substr($this->key, 0, 16);
$decrypted = openssl_decrypt($ciphertext_dec, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
} catch (Exception $e) {
return [ErrorCode::$DecryptAESError, null];
}
try {
//去除补位字符
$pkc_encoder = new PKCS7Encoder;
$result = $pkc_encoder->decode($decrypted);
//去除16位随机字符串,网络字节序和AppId
if (strlen($result) < 16) {
return "";
}
$content = substr($result, 16, strlen($result));
$len_list = unpack("N", substr($content, 0, 4));
$xml_len = $len_list[1];
$xml_content = substr($content, 4, $xml_len);
$from_appid = substr($content, $xml_len + 4);
} catch (Exception $e) {
//print $e;
return [ErrorCode::$IllegalBuffer, null];
}
if ($from_appid != $appid) {
return [ErrorCode::$ValidateAppidError, null];
}
return [0, $xml_content];
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace addons\kefu\library\WechatCrypto;
use addons\kefu\library\WechatCrypto\ErrorCode;
/**
* SHA1 class
*
* 计算公众平台的消息签名接口.
*/
class SHA1
{
/**
* 用SHA1算法生成安全签名
* @param string $token 票据
* @param string $timestamp 时间戳
* @param string $nonce 随机字符串
* @param string $encrypt 密文消息
*/
public function getSHA1($token, $timestamp, $nonce, $encrypt_msg)
{
//排序
try {
$array = [$encrypt_msg, $token, $timestamp, $nonce];
sort($array, SORT_STRING);
$str = implode($array);
return [ErrorCode::$OK, sha1($str)];
} catch (Exception $e) {
//print $e . "\n";
return [ErrorCode::$ComputeSignatureError, null];
}
}
}
?>
+323
View File
@@ -0,0 +1,323 @@
<?php
/**
* 对公众平台发送给公众账号的消息加解密代码.
* 白衣素袖:增加消息处理辅助方法
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
namespace addons\kefu\library\WechatCrypto;
use think\Db;
use EasyWeChat\Factory;
use GatewayWorker\Lib\Gateway;
use addons\kefu\library\Common;
use addons\kefu\library\WechatCrypto\SHA1;
use addons\kefu\library\WechatCrypto\ErrorCode;
use addons\kefu\library\WechatCrypto\XMLParse;
use addons\kefu\library\WechatCrypto\Prpcrypt;
/**
* 1.第三方回复加密消息给公众平台;
* 2.第三方收到公众平台发送的消息,验证消息的安全性,并对消息进行解密。
*/
class WXBizMsgCrypt
{
// 微信配置
private $wechat = [];
// EasyWeChat APP
public $wechatapp = null;
/**
* 构造函数
*/
public function __construct()
{
$wechat_temp = Db::name('kefu_config')
->whereIn('name', 'wechat_app_id,wechat_app_secret,wechat_token,wechat_encodingkey')
->select();
foreach ($wechat_temp as $key => $value) {
$this->wechat[$value['name']] = $value['value'];
}
// 初始化EasyWeChat
$config = [
'app_id' => $this->wechat['wechat_app_id'],
'secret' => $this->wechat['wechat_app_secret'],
'token' => $this->wechat['wechat_token'],
'aes_key' => $this->wechat['wechat_encodingkey'],
/*'log' => [
'level' => 'debug',
'file' => RUNTIME_PATH . 'log/kefu_wechat.log',
],*/
];
$this->wechatapp = Factory::miniProgram($config);
}
/**
* 将公众平台回复用户的消息加密打包.
* <ol>
* <li>对要发送的消息进行AES-CBC加密</li>
* <li>生成安全签名</li>
* <li>将消息密文和安全签名打包成xml格式</li>
* </ol>
*
* @param $replyMsg string 公众平台待回复用户的消息,xml格式的字符串
* @param $timeStamp string 时间戳,可以自己生成,也可以用URL参数的timestamp
* @param $nonce string 随机串,可以自己生成,也可以用URL参数的nonce
* @param &$encryptMsg string 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
* 当return返回0时有效
*
* @return int 成功0,失败返回对应的错误码
*/
public function encryptMsg($replyMsg, $timeStamp, $nonce, &$encryptMsg)
{
$pc = new Prpcrypt($this->wechat['wechat_encodingkey']);
//加密
$array = $pc->encrypt($replyMsg, $this->wechat['wechat_app_id']);
$ret = $array[0];
if ($ret != 0) {
return $ret;
}
if ($timeStamp == null) {
$timeStamp = time();
}
$encrypt = $array[1];
//生成安全签名
$sha1 = new SHA1;
$array = $sha1->getSHA1($this->wechat['wechat_token'], $timeStamp, $nonce, $encrypt);
$ret = $array[0];
if ($ret != 0) {
return $ret;
}
$signature = $array[1];
//生成发送的xml
$xmlparse = new XMLParse;
$encryptMsg = $xmlparse->generate($encrypt, $signature, $timeStamp, $nonce);
return ErrorCode::$OK;
}
/**
* 检验消息的真实性,并且获取解密后的明文.
* <ol>
* <li>利用收到的密文生成安全签名,进行签名验证</li>
* <li>若验证通过,则提取xml中的加密消息</li>
* <li>对消息进行解密</li>
* </ol>
*
* @param $msgSignature string 签名串,对应URL参数的msg_signature
* @param $timestamp string 时间戳 对应URL参数的timestamp
* @param $nonce string 随机串,对应URL参数的nonce
* @param $postData string 密文,对应POST请求的数据
* @param &$msg string 解密后的原文,当return返回0时有效
*
* @return int 成功0,失败返回对应的错误码
*/
public function decryptMsg($msgSignature, $timestamp = null, $nonce, $encrypt, &$msg)
{
if (strlen($this->wechat['wechat_encodingkey']) != 43) {
return ErrorCode::$IllegalAesKey;
}
$pc = new Prpcrypt($this->wechat['wechat_encodingkey']);
if ($timestamp == null) {
$timestamp = time();
}
//验证安全签名
$sha1 = new SHA1;
$array = $sha1->getSHA1($this->wechat['wechat_token'], $timestamp, $nonce, $encrypt);
$ret = $array[0];
if ($ret != 0) {
return $ret;
}
$signature = $array[1];
if ($signature != $msgSignature) {
return ErrorCode::$ValidateSignatureError;
}
$result = $pc->decrypt($encrypt, $this->wechat['wechat_app_id']);
if ($result[0] != 0) {
return $result[0];
}
$msg = $result[1];
return ErrorCode::$OK;
}
/**
* 保存用户发送的图片至服务器
* @param [type] $media_id 临时素材ID
* @param string $save_dir 保存目录
* @return string 文件完整路径
*/
public function saveImg($media_id, $save_dir = './uploads/')
{
$stream = $this->wechatapp->media->get($media_id);
if ($stream instanceof \EasyWeChat\Kernel\Http\StreamResponse) {
$save_dir = $save_dir . date('Ymd') . '/';
if (!is_dir($save_dir)) {
if (!mkdir($save_dir, 0777, true)) {
return false;
}
}
$filename = $stream->save($save_dir);
return $save_dir . $filename;
} else {
return false;
}
}
/**
* 发送消息至小程序用户
* @param [type] $message 文本或消息对象
* @param [type] $openid [description]
* @param [type] $sender 带标识的发送人
* @return [type] [description]
*/
public function sendMessage($message, $openid, $sender = false)
{
$res = $this->wechatapp->customer_service->message($message)->to($openid)->send();
switch ($res['errcode']) {
case '-1':
$msg = '发送失败,系统繁忙,请重试!';
break;
case '40001':
$msg = '发送失败,AppSecret错误!';
break;
case '40002':
$msg = '发送失败,凭证不合法!';
break;
case '40003':
$msg = '发送失败,openid不合法!';
break;
case '45015':
$msg = '发送失败,回复时间超限制!';
break;
case '45047':
$msg = '发送条数超限,用户将不会收到此消息!';
break;
case '48001':
$msg = '请确保小程序已获取发送客服消息功能的API权限!';
break;
default:
$msg = '';
break;
}
// 只在workerman环境(客服发送消息时),进行提示
if (class_exists('\GatewayWorker\Lib\Gateway') && $msg && $sender) {
Gateway::sendToUid($sender, json_encode([
'code' => 0,
'msgtype' => 'show_msg',
'msg' => $msg,
]));
}
return true;
}
/**
* 建立用户->分配客服
* @param [type] $open_id [description]
* @return [type] [description]
*/
public function userInitialize($open_id)
{
$kefu_user = Db::name('kefu_user')->where('wechat_openid', $open_id)->find();
if (!$kefu_user) {
// 建立用户
$tourists_max_id = Db::name('kefu_user')->max('id');
$kefu_user = [
'avatar' => '', // 随机头像->算了算了
'nickname' => '小程序用户 ' . $tourists_max_id,
'wechat_openid' => $open_id,
'createtime' => time(),
];
if (Db::name('kefu_user')->insert($kefu_user)) {
$kefu_user['id'] = Db::name('kefu_user')->getLastInsID();
}
}
// 查询之前的客服代表
$session = Db::name('kefu_session')
->alias('s')
->field('s.*,a.id as admin_id,a.nickname')
->join('admin a', 's.csr_id=a.id')
->where('s.user_id', $kefu_user['id'])
->where('s.deletetime', null)
->find();
// 有客服代表,但客服代表不在线,重新分配
$is_csr_distribution = false;
if ($session) {
$csr_status = Db::name('kefu_csr_config')->where('admin_id', $session['admin_id'])->value('status');
if ($csr_status != 3) {
$is_csr_distribution = true;
}
}
if (!$session || $is_csr_distribution) {
// 客服分配
$csr = Common::getAppropriateCsr();
if ($csr) {
$session = Common::distributionCsr($csr, $kefu_user['id'] . '||user');
} else {
$data = [
'session' => false,
'kefu_user' => $kefu_user,
'code' => 1,
'msg' => '非常抱歉,当前无在线客服,您可以直接在此留言,谢谢您的支持!',
];
return $data;
}
}
if ($session) {
// 记录客服接待人数
Db::name('kefu_csr_config')->where('admin_id', $session['admin_id'])->inc('reception_count')->update([
'last_reception_time' => time(),
]);
$data = [
'session' => $session,
'kefu_user' => $kefu_user,
'code' => 2,
];
return $data;
} else {
$data = [
'session' => false,
'code' => 0,
'msg' => '分配客服代表失败!',
];
return $data;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace addons\kefu\library\WechatCrypto;
use addons\kefu\library\WechatCrypto\ErrorCode;
/**
* XMLParse class
*
* 提供提取消息格式中的密文及生成回复消息格式的接口.
*/
class XMLParse
{
/**
* 提取出xml数据包中的加密消息
* @param string $xmltext 待提取的xml字符串
* @return string 提取出的加密消息字符串
*/
public function extract($xmltext)
{
libxml_disable_entity_loader(true);
try {
$xml = new DOMDocument();
$xml->loadXML($xmltext);
$array_e = $xml->getElementsByTagName('Encrypt');
$array_a = $xml->getElementsByTagName('ToUserName');
$encrypt = $array_e->item(0)->nodeValue;
$tousername = $array_a->item(0)->nodeValue;
return [0, $encrypt, $tousername];
} catch (Exception $e) {
//print $e . "\n";
return [ErrorCode::$ParseXmlError, null, null];
}
}
/**
* 生成xml消息
* @param string $encrypt 加密后的消息密文
* @param string $signature 安全签名
* @param string $timestamp 时间戳
* @param string $nonce 随机字符串
*/
public function generate($encrypt, $signature, $timestamp, $nonce)
{
$format = "<xml>
<Encrypt><![CDATA[%s]]></Encrypt>
<MsgSignature><![CDATA[%s]]></MsgSignature>
<TimeStamp>%s</TimeStamp>
<Nonce><![CDATA[%s]]></Nonce>
</xml>";
return sprintf($format, $encrypt, $signature, $timestamp, $nonce);
}
}
?>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{$chat_name}</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- 在线客服依赖以下css和js文件,请按需引入 -->
<link href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
<link href="__CDN__/assets/addons/kefu/css/kefu_default.css" rel="stylesheet">
<script src=https://cdn.staticfile.org/jquery/2.1.4/jquery.min.js></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="https://cdn.staticfile.org/layer/2.3/layer.js"></script>
<script src="__CDN__/assets/addons/kefu/js/kefu.js"></script>
</head>
<body>
<script type="text/javascript">
KeFu.initialize(document.domain, 'index', function () {
KeFu.toggle_window('show');// 打开窗口
});
// 参数一为在线客服所在网站的域名(启动Workerman服务的网站的域名)
// 参数二为模块名,站外直接填写index
// 参数三为初始化完成后的回调方法
// 参数四为指定客服,可在此处填写客服代表的后台账户id
// 若要站外调用,请于后台-》插件管理-》本插件的配置中-》跨站调用允许域名-》填写外站的域名
</script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
<!-- FastAdmin在线客服插件不是开源产品,所有文字、图片、样式、风格等版权归在线客服作者所有,如有复制、仿冒、抄袭、盗用,FastAdmin和在线客服作者将追究法律责任 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{$chat_name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="renderer" content="webkit">
<link href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link href="__CDN__/assets/addons/kefu/css/kefu_mobile.css" rel="stylesheet">
</head>
<body>
<div class="kefu_container">
<header class="main_header">
<span id="header_title"></span>
<span id="csr_status" style="color: rgb(60, 118, 61);"></span>
<span id="kefu_error">链接中...</span>
<!-- <i class="fa fa-close fa-lg fa-fw back_page"></i> -->
</header>
<div class="content_wrapper">
<!-- 聊天消息列表 -->
<div class="chat_wrapper"></div>
<!--留言板-->
<div id="kefu_leave_message" style="display: none;" class="panel panel-success kefu_window_view">
<div class="panel-heading">非常抱歉,当前无客服在线,您留言后我们将尽快与您联系</div>
<div class="panel-body">
<form method="get" action="" class="form-horizontal">
<div class="form-group">
<label for="c-name" class="col-sm-2 control-label">姓名</label>
<div class="col-sm-10">
<input type="text" name="name" class="form-control" id="c-name" placeholder="请输入您的姓名">
</div>
</div>
<div class="form-group">
<label for="c-contact" class="col-sm-2 control-label">联系方式</label>
<div class="col-sm-10">
<input type="text" name="contact" class="form-control" id="c-contact" placeholder="请输入手机/QQ/微信号">
</div>
</div>
<div class="form-group">
<label for="c-message" class="col-sm-2 control-label">留言内容</label>
<div class="col-sm-10">
<textarea rows="5" name="message" class="form-control" id="c-message" placeholder="遇到的问题、所需服务、产品等,我们将尽快与您取得联系"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="button" class="btn btn-success">确认留言</button>
</div>
</div>
</form>
</div>
</div>
<!-- 消息输入 -->
<div id="kefu_emoji"></div>
<!--客服输入状态-->
<div id="kefu_input_status"></div>
<div class="write">
<div class="widget_textarea">
<textarea id="kefu_message" spellcheck="false" style="height: 35px;"></textarea>
</div>
<div class="write_right">
{present name="toolbar.expression"}
<i class="smiley" title="{$toolbar.expression.title}" style="background: url('{$toolbar.expression.icon_image}') no-repeat center;background-size: 100% 100%;"></i>
{/present}
<i class="more" style="background:url('__CDN__/assets/addons/kefu/img/more.png') no-repeat center;background-size:100% 100%;"></i>
<button class="btn btn-success btn-sm send_btn">发送</button>
</div>
</div>
<div style="display: none;" class="footer_div">
<div class="toolbar">
{present name="toolbar.file"}
<div class="toolbar_item select_file">
<input id="chatfile" size="1" width="20" type="file" name="chatfile">
<img src="{$toolbar.file.icon_image}" alt="{$toolbar.file.title}" />
<div>{$toolbar.file.title}</div>
</div>
{/present}
{present name="toolbar.goods"}
<div class="toolbar_item" data-toolbar_type="goods">
<img src="{$toolbar.goods.icon_image}" alt="{$toolbar.goods.title}" />
<div>{$toolbar.goods.title}</div>
</div>
{/present}
{present name="toolbar.order"}
<div class="toolbar_item" data-toolbar_type="order">
<img src="{$toolbar.order.icon_image}" alt="{$toolbar.order.title}" />
<div>{$toolbar.order.title}</div>
</div>
{/present}
</div>
</div>
<div style="display: none;" class="select_model">
<div class="close_select_model">关闭</div>
<div class="card_list"></div>
</div>
</div>
</div>
<script src=https://cdn.staticfile.org/jquery/2.1.4/jquery.min.js></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="https://cdn.staticfile.org/layer/2.3/layer.js"></script>
<script src="__CDN__/assets/addons/kefu/js/kefu_mobile.js"></script>
<script>
KeFu.initialize(document.domain, 'index');
</script>
</body>
</html>
+262
View File
@@ -0,0 +1,262 @@
<!-- KeFu-Admin-Template -->
<ins class="KeFu">
<!-- 右侧悬浮按钮 -->
<img src="{$cdnurl}/assets/addons/kefu/img/buoy1.png" class="kefu_button" id="kefu_button" data-html="true" data-container="body" data-toggle="popover" data-placement="left" data-content="" alt="客服浮标" />
<!-- 右侧悬浮按钮-end -->
<!-- 聊天窗口 -->
<div class="modal fade bs-example-modal-lg" id="KeFuModal" tabindex="-1" role="dialog" aria-labelledby="KeFuModal">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">
<span id="modal-title">在线客服</span>
<div class="dropdown" id="kefu_csr_status">
<button class="btn btn-default btn-xs dropdown-toggle" type="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<span class="kefu_status">状态</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="kefu_status_menu">
<li><a data-status="3" href="#">在线</a></li>
<li><a data-status="2" href="#">离开</a></li>
<li><a data-status="1" href="#">繁忙</a></li>
</ul>
</div>
<span id="kefu_error">链接中...</span>
</h4>
</div>
<div class="modal-body">
<div class="kefu-left">
<div class="top">
<input type="text" autocomplete="off" id="kefu_search_input" placeholder="搜索其实很简单"/>
</div>
<div id="session_list_search" class="search_users">
</div>
<div class="panel-group" id="session_panel" role="tablist" aria-multiselectable="false">
<!-- data-parent="#session_panel" 若需自动折叠,可在a标签上添加此属性 -->
<div class="panel panel-info dialogue_panel">
<div class="panel-heading" role="tab" id="heading_dialogue">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" href="#collapse_dialogue"
aria-expanded="false" aria-controls="collapse_dialogue">对话中<span
class="red_dot text-danger"></span></a>
</h4>
</div>
<div id="collapse_dialogue" class="panel-collapse collapse" role="tabpanel"
aria-labelledby="heading_dialogue">
<div class="panel-body">
<ul id="session_list_dialogue" class="people">
<div class="none_session">这里没有会话哦~</div>
</ul>
</div>
</div>
</div>
<div class="panel panel-info invitation_panel">
<div class="panel-heading" role="tab" id="heading_invitation">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" href="#collapse_invitation"
aria-expanded="false" aria-controls="collapse_invitation">可邀请<span
class="red_dot text-danger"></span></a>
</h4>
</div>
<div id="collapse_invitation" class="panel-collapse collapse" role="tabpanel"
aria-labelledby="heading_invitation">
<div class="panel-body">
<ul id="session_list_invitation" class="people">
<div class="none_session">这里没有会话哦~</div>
</ul>
</div>
</div>
</div>
<div class="panel panel-info recently_panel">
<div class="panel-heading" role="tab" id="heading_recently">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" href="#collapse_recently"
aria-expanded="false" aria-controls="collapse_recently">最近沟通<span
class="red_dot text-danger"></span></a>
</h4>
</div>
<div id="collapse_recently" class="panel-collapse collapse" role="tabpanel"
aria-labelledby="heading_recently">
<div class="panel-body">
<ul id="session_list_recently" class="people">
<div class="none_session">这里没有会话哦~</div>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="kefu-right">
<div class="top">
<span>
<span id="session_user_name" class="name">无会话</span>
<span class="top_span" id="kefu_blacklist">拉黑名单</span>
<span class="top_span" id="kefu_view_trajectory">查看轨迹</span>
<span class="top_span" id="kefu_view_user_card">用户名片</span>
</span>
</div>
<div class="chat">
<div class="chat_scroll kefu_window_view" id="kefu_scroll">
</div>
<div class="VivaTimeline kefu_window_view" id="kefu_trajectory">
<dl id="kefu_trajectory_log"></dl>
</div>
<div class="panel panel-success kefu_window_view" id="kefu_user_card">
<div class="panel-heading">用户名片</div>
<div class="panel-body">
<form method="get" action="" class="form-horizontal">
<div class="form-group">
<label class="col-sm-3 control-label">用户</label>
<div class="col-sm-7">
<input id="card-user" type="text" readonly="readonly"
class="form-control"/>
</div>
</div>
<div class="form-group">
<label for="card-nickname" class="col-sm-3 control-label">用户昵称</label>
<div class="col-sm-7">
<input id="card-nickname" name="nickname" type="text"
placeholder="请输入用户昵称" class="form-control"/>
</div>
</div>
<div class="form-group">
<label for="card-referrer" class="col-sm-3 control-label">用户来路</label>
<div class="col-sm-7">
<textarea id="card-referrer" name="referrer" rows="2"
class="form-control" placeholder=""></textarea>
</div>
</div>
<div class="form-group">
<label for="card-contact" class="col-sm-3 control-label">联系方式</label>
<div class="col-sm-7">
<textarea id="card-contact" name="contact" rows="2" class="form-control"
placeholder="QQ/微信/手机号等"></textarea>
</div>
</div>
<div class="form-group">
<label for="card-note" class="col-sm-3 control-label">备注</label>
<div class="col-sm-7">
<textarea id="card-note" name="note" rows="4" class="form-control"
placeholder="请输入备注,备注信息用户不可见"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-7">
<button id="card-button" type="button" class="btn btn-success">确认修改
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- 按需渲染表情包 -->
{present name="toolbar.expression"}
<div class="kefu_emoji">
<div id="kefu_emoji">
{for start="1" end="37"}
<img class="emoji" title="" src="{$cdnurl}/assets/addons/kefu/img/emoji/{$i}.png">
{/for}
</div>
</div>
{/present}
<!-- 按需渲染自动回复 -->
{present name="toolbar.fastreply"}
<div class="kefu_fast_reply_list">
<table class="table table-striped table-hover" id="kefu_fast_reply_list">
{volist name="fast_reply" id="reply"}
<tr data-id='{$reply.id}'>
<td>{$reply.title}</td>
</tr>
{/volist}
</table>
</div>
{/present}
<!-- 准备链接输入面板 -->
{present name="toolbar.link"}
<div class="kefu_link_form">
<div id="kefu_link_form">
<div class="input-group">
<span class="input-group-addon">链接说明</span>
<input type="text" placeholder="选填" class="form-control" id="kefu_link_form_ins"/>
</div>
<div class="input-group">
<span class="input-group-addon">链接地址</span>
<input type="text" value="http://" class="form-control" id="kefu_link_form_url"/>
</div>
<div class="link_form_buttons">
<button type="button" class="btn btn-success btn-sm">确认</button>
<button type="button" class="btn btn-default btn-sm">取消</button>
</div>
</div>
</div>
{/present}
<!--用户输入状态-->
<div class="input_status">
<div id="kefu_input_status"></div>
</div>
<div class="write">
<div class="write_top">
{present name="toolbar.expression"}
<i class="toolbar_icon smiley" title="{$toolbar.expression.title}" style="background: url('{$toolbar.expression.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}
{present name="toolbar.file"}
<div class="select_file">
<input title="{$toolbar.file.title}" id="chatfile" size="1" width="20" type="file" name="chatfile">
<i class="attach toolbar_icon" style="background: url('{$toolbar.file.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
</div>
{/present}
{present name="toolbar.link"}
<i class="toolbar_icon link" title="{$toolbar.link.title}" style="background: url('{$toolbar.link.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}
<span title="按下Ctrl+Enter换行" class="write_top_span" id="send_tis">按下Enter发送消息</span>
{present name="toolbar.fastreply"}
<span class="write_top_span kefu_fast_reply">{$toolbar.fastreply.title}</span>
{/present}
</div>
<pre contenteditable="plaintext-only" id="kefu_message"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 客服端右击用户菜单 -->
<ul class="dropdown-menu" style="z-index: 1060;" id="kefu_menu">
<li class="kefu_menu_item transfer" data-action="transfer"><a href="#">转接会话</a></li>
<li class="kefu_menu_item invitation" data-action="invitation"><a href="#">邀请对话</a></li>
<li class="kefu_menu_item" data-action="edit_nickname"><a href="#">修改昵称</a></li>
<li class="kefu_menu_item" data-action="del"><a href="#">删除会话</a></li>
</ul>
<!-- 聊天窗口-end -->
</ins>
<!-- KeFu-Template-end
+149
View File
@@ -0,0 +1,149 @@
<!-- KeFu-Template -->
<ins class="KeFu">
<!-- 右侧悬浮按钮 -->
<img src="{$cdnurl}/assets/addons/kefu/img/buoy1.png" class="kefu_button" id="kefu_button" data-html="true" data-container="body" data-toggle="popover" data-placement="left" data-content="" alt="客服浮标" />
<!-- 右侧悬浮按钮-end -->
<!-- 聊天窗口 -->
<div class="modal fade bs-example-modal-lg" id="KeFuModal" tabindex="-1" role="dialog" aria-labelledby="KeFuModal">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">
<span id="modal-title"></span>
<span id="csr_status"></span>
<span id="kefu_error">链接中...</span>
</h4>
</div>
<div class="modal-body">
<div class="kefu-left">
<div class="alert alert-warning-light announcement">
<i class="fa fa-bell-o"></i><span id="announcement"></span>
</div>
<div class="chat">
<div class="chat_scroll kefu_window_view" id="kefu_scroll"></div>
<div id="kefu_leave_message" style="display: none;" class="panel panel-success kefu_window_view">
<div class="panel-heading">非常抱歉,当前无客服在线,您留言后我们将尽快与您联系</div>
<div class="panel-body">
<form method="get" action="" class="form-horizontal">
<div class="form-group">
<label for="c-name" class="col-sm-2 control-label">姓名</label>
<div class="col-sm-10">
<input type="text" name="name" class="form-control" id="c-name" placeholder="请输入您的姓名">
</div>
</div>
<div class="form-group">
<label for="c-contact" class="col-sm-2 control-label">联系方式</label>
<div class="col-sm-10">
<input type="text" name="contact" class="form-control" id="c-contact" placeholder="请输入手机/QQ/微信号">
</div>
</div>
<div class="form-group">
<label for="c-message" class="col-sm-2 control-label">留言内容</label>
<div class="col-sm-10">
<textarea rows="5" name="message" class="form-control" id="c-message" placeholder="遇到的问题、所需服务、产品等,我们将尽快与您取得联系"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="button" class="btn btn-success">确认留言</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- 按需渲染表情包 -->
{present name="toolbar.expression"}
<div class="kefu_emoji">
<div id="kefu_emoji">
{for start="1" end="37"}
<img class="emoji" src="{$cdnurl}/assets/addons/kefu/img/emoji/{$i}.png">
{/for}
</div>
</div>
{/present}
<!-- 准备链接输入面板 -->
{present name="toolbar.link"}
<div class="kefu_link_form">
<div id="kefu_link_form">
<div class="input-group">
<span class="input-group-addon">链接说明</span>
<input type="text" placeholder="选填" class="form-control" id="kefu_link_form_ins"/>
</div>
<div class="input-group">
<span class="input-group-addon">链接地址</span>
<input type="text" value="http://" class="form-control" id="kefu_link_form_url"/>
</div>
<div class="link_form_buttons">
<button type="button" class="btn btn-success btn-sm">确认</button>
<button type="button" class="btn btn-default btn-sm">取消</button>
</div>
</div>
</div>
{/present}
<!--商品和订单选择框-->
<div class="goods_select_model">
<div id="goods_select_model">
<div class="project_list"></div>
</div>
</div>
<!--客服输入状态-->
<div class="input_status">
<div id="kefu_input_status"></div>
</div>
<div class="write">
<div class="write_top">
{present name="toolbar.expression"}
<i class="toolbar_icon smiley" title="{$toolbar.expression.title}" style="background: url('{$toolbar.expression.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}
{present name="toolbar.file"}
<div class="select_file">
<input title="{$toolbar.file.title}" id="chatfile" size="1" width="20" type="file" name="chatfile">
<i class="attach toolbar_icon" style="background: url('{$toolbar.file.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
</div>
{/present}
<!--防止xss,链接功能取消-->
<!--{present name="toolbar.link"}
<i class="toolbar_icon link" title="{$toolbar.link.title}" style="background: url('{$toolbar.link.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}-->
{present name="toolbar.goods"}
<i class="toolbar_icon goods" title="{$toolbar.goods.title}" style="background: url('{$toolbar.goods.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}
{present name="toolbar.order"}
<i class="toolbar_icon order" title="{$toolbar.order.title}" style="background: url('{$toolbar.order.icon_image}') no-repeat center;background-size: 80% 80%;"></i>
{/present}
<span title="按下Ctrl+Enter换行" id="send_tis">按下Enter发送消息</span>
</div>
<pre contenteditable="plaintext-only" id="kefu_message"></pre>
</div>
</div>
<div class="kefu-right">
<div id="kefu_chat_slide_f"></div>
<div class="chat_introduces"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 聊天窗口-end -->
</ins>
<!-- KeFu-Template-end -->