号码管理
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
-- 分流管理 - 号码管理表与菜单权限
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `fa_split_number`;
|
||||||
|
CREATE TABLE `fa_split_number` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '号码ID',
|
||||||
|
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||||
|
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||||
|
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||||
|
`number` varchar(50) NOT NULL DEFAULT '' COMMENT '号码',
|
||||||
|
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||||
|
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||||
|
`visit_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '访问次数',
|
||||||
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数',
|
||||||
|
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是',
|
||||||
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_link_number` (`split_link_id`,`number`),
|
||||||
|
KEY `admin_id` (`admin_id`),
|
||||||
|
KEY `split_link_id` (`split_link_id`),
|
||||||
|
KEY `status` (`status`),
|
||||||
|
KEY `number` (`number`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流号码表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', r.id, 'split.number', '号码管理', 'fa fa-phone', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` r
|
||||||
|
WHERE r.name = 'split' AND r.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/index' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/add', '添加', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/add' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/edit', '编辑', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/edit' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/del', '删除', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/del' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/batchupdate', '批量更新号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- 分流管理 - 工单管理表与菜单权限
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `fa_split_ticket`;
|
||||||
|
CREATE TABLE `fa_split_ticket` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||||
|
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||||
|
`ticket_type` varchar(32) NOT NULL DEFAULT '' COMMENT '工单类型',
|
||||||
|
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||||
|
`ticket_url` varchar(1000) NOT NULL DEFAULT '' COMMENT '工单链接',
|
||||||
|
`ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量',
|
||||||
|
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||||
|
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||||
|
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||||
|
`start_time` bigint(16) DEFAULT NULL COMMENT '开始时间',
|
||||||
|
`end_time` bigint(16) DEFAULT NULL COMMENT '到期时间',
|
||||||
|
`order_limit` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单号上限',
|
||||||
|
`assign_ratio` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率',
|
||||||
|
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
||||||
|
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
||||||
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
||||||
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
||||||
|
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
||||||
|
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
||||||
|
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
||||||
|
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
||||||
|
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
||||||
|
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
||||||
|
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
||||||
|
`sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要',
|
||||||
|
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `admin_id` (`admin_id`),
|
||||||
|
KEY `split_link_id` (`split_link_id`),
|
||||||
|
KEY `ticket_type` (`ticket_type`),
|
||||||
|
KEY `start_time` (`start_time`),
|
||||||
|
KEY `end_time` (`end_time`),
|
||||||
|
KEY `status` (`status`),
|
||||||
|
KEY `sync_status` (`sync_status`),
|
||||||
|
KEY `sync_time` (`sync_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流工单表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- 菜单:工单管理(父级 split)
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', r.id, 'split.ticket', '工单管理', 'fa fa-ticket', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` r
|
||||||
|
WHERE r.name = 'split' AND r.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/index' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/add', '添加', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/add' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/edit', '编辑', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/edit' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/del', '删除', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/del' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/multi', '批量更新', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/multi' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 工单表:统计字段 + ticket_total 整数化(供列表展示与后续同步接口写入)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1. 同步统计字段(若已存在则跳过需手工处理)
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)' AFTER `status`,
|
||||||
|
ADD COLUMN `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)' AFTER `complete_count`,
|
||||||
|
ADD COLUMN `speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)' AFTER `inbound_count`,
|
||||||
|
ADD COLUMN `number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)' AFTER `speed_per_hour`,
|
||||||
|
ADD COLUMN `number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)' AFTER `number_count`,
|
||||||
|
ADD COLUMN `number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)' AFTER `number_offline_count`,
|
||||||
|
ADD COLUMN `online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)' AFTER `number_banned_count`,
|
||||||
|
ADD COLUMN `sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态' AFTER `online_count`,
|
||||||
|
ADD COLUMN `sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间' AFTER `sync_status`,
|
||||||
|
ADD COLUMN `sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要' AFTER `sync_time`;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD KEY `sync_status` (`sync_status`),
|
||||||
|
ADD KEY `sync_time` (`sync_time`);
|
||||||
|
|
||||||
|
-- 2. ticket_total:varchar -> int unsigned(非数字旧值变为 0)
|
||||||
|
UPDATE `fa_split_ticket` SET `ticket_total` = '0' WHERE `ticket_total` REGEXP '[^0-9]' OR `ticket_total` = '';
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量';
|
||||||
|
|
||||||
|
-- 3. 号码表组合索引:加速按工单汇总 visit_count
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD KEY `idx_ticket_agg` (`admin_id`, `split_link_id`, `ticket_name`(50));
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link as LinkModel;
|
||||||
|
use app\admin\model\split\Number as NumberModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\DbException;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
use think\response\Json;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-phone
|
||||||
|
* @remark 分流号码管理,关联分流链接
|
||||||
|
*/
|
||||||
|
class Number extends Backend
|
||||||
|
{
|
||||||
|
/** @var NumberModel */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'ticket_name,number';
|
||||||
|
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
|
||||||
|
protected $modelValidate = true;
|
||||||
|
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
protected $noNeedRight = ['script'];
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/number/';
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
|
||||||
|
$lang = $this->request->langset();
|
||||||
|
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||||
|
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/number.php';
|
||||||
|
if (is_file($langFile)) {
|
||||||
|
\think\Lang::load($langFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->model = new NumberModel();
|
||||||
|
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->view->assign('statusList', $this->model->getStatusList());
|
||||||
|
$this->view->assign('manualManageList', $this->model->getManualManageList());
|
||||||
|
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||||
|
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
|
|
||||||
|
$this->setupPatchFrontend();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupPatchFrontend(): void
|
||||||
|
{
|
||||||
|
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||||
|
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||||
|
$usePatchJs = is_file($patchJs) && (
|
||||||
|
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||||
|
);
|
||||||
|
if (!$usePatchJs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||||
|
$version = (string) \think\Config::get('site.version');
|
||||||
|
$scriptUrl = (string) url('split.number/script', ['v' => $version], false, true);
|
||||||
|
if (strpos($scriptUrl, '?') === false) {
|
||||||
|
$scriptUrl .= '?v=' . $version;
|
||||||
|
}
|
||||||
|
if (strpos($scriptUrl, '://') === false) {
|
||||||
|
$scriptUrl = $this->request->domain() . $scriptUrl;
|
||||||
|
}
|
||||||
|
$cfg['jsname'] = $scriptUrl;
|
||||||
|
$this->view->assign('config', $cfg);
|
||||||
|
$this->view->config = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
private function buildSplitLinkList(): array
|
||||||
|
{
|
||||||
|
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[] = [
|
||||||
|
'id' => (string) $row['id'],
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPatch(string $template): string
|
||||||
|
{
|
||||||
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
|
$appFile = APP_PATH . 'admin/view/split/number/' . $template . '.html';
|
||||||
|
if (is_file($patchFile)) {
|
||||||
|
$file = $patchFile;
|
||||||
|
} elseif (is_file($appFile)) {
|
||||||
|
$file = $appFile;
|
||||||
|
} else {
|
||||||
|
$this->error('模板文件不存在');
|
||||||
|
}
|
||||||
|
return (string) $this->view->fetch($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|Json
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->request->filter(['strip_tags', 'trim']);
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
return $this->fetchPatch('index');
|
||||||
|
}
|
||||||
|
if ($this->request->request('keyField')) {
|
||||||
|
return $this->selectpage();
|
||||||
|
}
|
||||||
|
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||||
|
$list = $this->model
|
||||||
|
->with(['splitLink'])
|
||||||
|
->where($where)
|
||||||
|
->order($sort, $order)
|
||||||
|
->paginate($limit);
|
||||||
|
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||||
|
return json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->fetchPatch('add');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbersText = (string) ($params['numbers'] ?? '');
|
||||||
|
unset($params['numbers']);
|
||||||
|
$numberList = NumberModel::parseNumbersText($numbersText);
|
||||||
|
if ($numberList === []) {
|
||||||
|
$this->error(__('Please fill at least one number'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$validateData = array_merge($params, ['numbers' => $numbersText]);
|
||||||
|
$adminId = $this->dataLimit && $this->dataLimitFieldAutoFill ? (int) $this->auth->id : 0;
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate, $validateData);
|
||||||
|
}
|
||||||
|
$splitLinkId = (int) ($params['split_link_id'] ?? 0);
|
||||||
|
$baseRow = [
|
||||||
|
'split_link_id' => $splitLinkId,
|
||||||
|
'ticket_name' => (string) ($params['ticket_name'] ?? ''),
|
||||||
|
'number_type' => (string) ($params['number_type'] ?? ''),
|
||||||
|
'number_type_custom' => (string) ($params['number_type_custom'] ?? ''),
|
||||||
|
'status' => (string) ($params['status'] ?? 'normal'),
|
||||||
|
'visit_count' => 0,
|
||||||
|
'inbound_count' => 0,
|
||||||
|
'manual_manage' => 0,
|
||||||
|
];
|
||||||
|
if ($adminId > 0) {
|
||||||
|
$baseRow['admin_id'] = $adminId;
|
||||||
|
}
|
||||||
|
foreach ($numberList as $num) {
|
||||||
|
$exists = $this->model
|
||||||
|
->where('split_link_id', $splitLinkId)
|
||||||
|
->where('number', $num)
|
||||||
|
->find();
|
||||||
|
if ($exists) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row = $baseRow;
|
||||||
|
$row['number'] = $num;
|
||||||
|
$item = new NumberModel();
|
||||||
|
$item->allowField(true)->isUpdate(false)->save($row);
|
||||||
|
$inserted++;
|
||||||
|
}
|
||||||
|
if ($inserted === 0) {
|
||||||
|
throw new Exception(__('All numbers already exist for this link'));
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = __('Inserted %d number(s)', $inserted);
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
||||||
|
}
|
||||||
|
$this->success($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $ids
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->view->assign('row', $row);
|
||||||
|
return $this->fetchPatch('edit');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$newNumber = trim((string) ($params['number'] ?? ''));
|
||||||
|
$splitLinkId = (int) ($params['split_link_id'] ?? $row['split_link_id']);
|
||||||
|
$exists = $this->model
|
||||||
|
->where('split_link_id', $splitLinkId)
|
||||||
|
->where('number', $newNumber)
|
||||||
|
->where('id', '<>', (int) $row['id'])
|
||||||
|
->find();
|
||||||
|
if ($exists) {
|
||||||
|
$this->error(__('Number already exists for this link'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||||
|
$row->validateFailException()->validate($validate, $params);
|
||||||
|
}
|
||||||
|
$result = $row->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新号码状态与手动管理
|
||||||
|
*/
|
||||||
|
public function batchupdate(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
$ids = $this->request->post('ids', '');
|
||||||
|
if ($ids === '' || $ids === null) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
if (!is_array($ids)) {
|
||||||
|
$ids = explode(',', (string) $ids);
|
||||||
|
}
|
||||||
|
$ids = array_filter(array_map('intval', $ids));
|
||||||
|
if ($ids === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) $this->request->post('status', '');
|
||||||
|
$manualManage = $this->request->post('manual_manage', '');
|
||||||
|
$statusList = $this->model->getStatusList();
|
||||||
|
if (!isset($statusList[$status])) {
|
||||||
|
$this->error(__('Invalid status'));
|
||||||
|
}
|
||||||
|
if (!in_array((string) $manualManage, ['0', '1'], true)) {
|
||||||
|
$this->error(__('Invalid manual manage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->model->where('id', 'in', $ids);
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$count = $query->update([
|
||||||
|
'status' => $status,
|
||||||
|
'manual_manage' => (int) $manualManage,
|
||||||
|
]);
|
||||||
|
if ($count === false || $count === 0) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success(__('Batch update success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排除不可由表单提交的字段
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function preExcludeFields($params): array
|
||||||
|
{
|
||||||
|
$params = parent::preExcludeFields($params);
|
||||||
|
unset(
|
||||||
|
$params['visit_count'],
|
||||||
|
$params['inbound_count'],
|
||||||
|
$params['manual_manage'],
|
||||||
|
$params['id']
|
||||||
|
);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function script(): void
|
||||||
|
{
|
||||||
|
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||||
|
}
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$this->error('脚本文件不存在');
|
||||||
|
}
|
||||||
|
$content = file_get_contents($jsFile);
|
||||||
|
if ($content === false) {
|
||||||
|
$this->error('读取脚本失败');
|
||||||
|
}
|
||||||
|
$response = response($content, 200, [
|
||||||
|
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control' => 'public, max-age=3600',
|
||||||
|
]);
|
||||||
|
throw new \think\exception\HttpResponseException($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link as LinkModel;
|
||||||
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\DbException;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
use think\response\Json;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-ticket
|
||||||
|
* @remark 分流工单管理,关联分流链接
|
||||||
|
*/
|
||||||
|
class Ticket extends Backend
|
||||||
|
{
|
||||||
|
/** @var \app\admin\model\split\Ticket */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'ticket_name,ticket_url,ticket_total';
|
||||||
|
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
|
||||||
|
protected $modelValidate = true;
|
||||||
|
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
/** @var string[] 无需鉴权的方法 */
|
||||||
|
protected $noNeedRight = ['script'];
|
||||||
|
|
||||||
|
/** @var string patches 视图目录 */
|
||||||
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
|
||||||
|
$lang = $this->request->langset();
|
||||||
|
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||||
|
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php';
|
||||||
|
if (is_file($langFile)) {
|
||||||
|
\think\Lang::load($langFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->model = new \app\admin\model\split\Ticket();
|
||||||
|
$this->view->assign('ticketTypeList', $this->model->getTicketTypeList());
|
||||||
|
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->view->assign('statusList', $this->model->getStatusList());
|
||||||
|
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||||
|
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
|
||||||
|
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
|
||||||
|
$this->setupPatchFrontend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未部署 JS 时指向 script 接口
|
||||||
|
*/
|
||||||
|
private function setupPatchFrontend(): void
|
||||||
|
{
|
||||||
|
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||||
|
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||||
|
$usePatchJs = is_file($patchJs) && (
|
||||||
|
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||||
|
);
|
||||||
|
if (!$usePatchJs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||||
|
$version = (string) \think\Config::get('site.version');
|
||||||
|
$scriptUrl = (string) url('split.ticket/script', ['v' => $version], false, true);
|
||||||
|
if (strpos($scriptUrl, '?') === false) {
|
||||||
|
$scriptUrl .= '?v=' . $version;
|
||||||
|
}
|
||||||
|
if (strpos($scriptUrl, '://') === false) {
|
||||||
|
$scriptUrl = $this->request->domain() . $scriptUrl;
|
||||||
|
}
|
||||||
|
$cfg['jsname'] = $scriptUrl;
|
||||||
|
$this->view->assign('config', $cfg);
|
||||||
|
$this->view->config = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
private function buildSplitLinkList(): array
|
||||||
|
{
|
||||||
|
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[] = [
|
||||||
|
'id' => (string) $row['id'],
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPatch(string $template): string
|
||||||
|
{
|
||||||
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
|
$appFile = APP_PATH . 'admin/view/split/ticket/' . $template . '.html';
|
||||||
|
if (is_file($patchFile)) {
|
||||||
|
$file = $patchFile;
|
||||||
|
} elseif (is_file($appFile)) {
|
||||||
|
$file = $appFile;
|
||||||
|
} else {
|
||||||
|
$this->error('模板文件不存在');
|
||||||
|
}
|
||||||
|
return (string) $this->view->fetch($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|Json
|
||||||
|
* @throws DbException
|
||||||
|
* @throws \think\Exception
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->request->filter(['strip_tags', 'trim']);
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
return $this->fetchPatch('index');
|
||||||
|
}
|
||||||
|
if ($this->request->request('keyField')) {
|
||||||
|
return $this->selectpage();
|
||||||
|
}
|
||||||
|
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||||
|
$ticketTable = $this->model->getTable();
|
||||||
|
$clickSub = TicketModel::buildClickCountSubQuery();
|
||||||
|
$list = $this->model
|
||||||
|
->field($ticketTable . '.*')
|
||||||
|
->fieldRaw($clickSub)
|
||||||
|
->with(['splitLink'])
|
||||||
|
->where($where)
|
||||||
|
->order($sort, $order)
|
||||||
|
->paginate($limit);
|
||||||
|
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||||
|
return json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步统计字段仅由接口写入,禁止表单提交篡改
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function preExcludeFields($params): array
|
||||||
|
{
|
||||||
|
$params = parent::preExcludeFields($params);
|
||||||
|
unset(
|
||||||
|
$params['complete_count'],
|
||||||
|
$params['inbound_count'],
|
||||||
|
$params['speed_per_hour'],
|
||||||
|
$params['number_count'],
|
||||||
|
$params['number_offline_count'],
|
||||||
|
$params['number_banned_count'],
|
||||||
|
$params['online_count'],
|
||||||
|
$params['sync_status'],
|
||||||
|
$params['sync_time'],
|
||||||
|
$params['sync_message'],
|
||||||
|
$params['click_count']
|
||||||
|
);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->fetchPatch('add');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||||
|
$params[$this->dataLimitField] = $this->auth->id;
|
||||||
|
}
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate);
|
||||||
|
}
|
||||||
|
$result = $this->model->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were inserted'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $ids
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$rowData = $row->toArray();
|
||||||
|
$rowData['start_time'] = $rowData['start_time_text'] ?? '';
|
||||||
|
$rowData['end_time'] = $rowData['end_time_text'] ?? '';
|
||||||
|
$this->view->assign('row', $rowData);
|
||||||
|
return $this->fetchPatch('edit');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||||
|
$row->validateFailException()->validate($validate);
|
||||||
|
}
|
||||||
|
$result = $row->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出后台 JS(patches 未部署到 public 时)
|
||||||
|
*/
|
||||||
|
public function script(): void
|
||||||
|
{
|
||||||
|
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||||
|
}
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$this->error('脚本文件不存在');
|
||||||
|
}
|
||||||
|
$content = file_get_contents($jsFile);
|
||||||
|
if ($content === false) {
|
||||||
|
$this->error('读取脚本失败');
|
||||||
|
}
|
||||||
|
$response = response($content, 200, [
|
||||||
|
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control' => 'public, max-age=3600',
|
||||||
|
]);
|
||||||
|
throw new \think\exception\HttpResponseException($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Id' => '号码ID',
|
||||||
|
'Link_url' => '链接URL',
|
||||||
|
'Ticket_name' => '工单名称',
|
||||||
|
'Number' => '号码',
|
||||||
|
'Numbers' => '号码',
|
||||||
|
'Number_type' => '号码类型',
|
||||||
|
'Number_type_custom' => '自定义号码类型',
|
||||||
|
'Split_link_id' => '选择链接',
|
||||||
|
'Visit_count' => '访问次数',
|
||||||
|
'Inbound_count' => '进线人数',
|
||||||
|
'Status' => '状态',
|
||||||
|
'Manual_manage' => '手动管理',
|
||||||
|
'Createtime' => '创建时间',
|
||||||
|
'Section basic' => '基础信息',
|
||||||
|
'Status normal' => '正常',
|
||||||
|
'Status hidden' => '停用',
|
||||||
|
'Number type custom' => '自定义',
|
||||||
|
'Manual manage yes' => '是',
|
||||||
|
'Manual manage no' => '否',
|
||||||
|
'Please select' => '请选择',
|
||||||
|
'Numbers placeholder' => '每行填写一个号码',
|
||||||
|
'Batch update title' => '批量更新号码',
|
||||||
|
'Batch selected count' => '已选号码',
|
||||||
|
'Batch update status' => '更新状态',
|
||||||
|
'Batch update btn' => '更新状态',
|
||||||
|
'Please fill at least one number' => '请至少填写一个有效号码',
|
||||||
|
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
|
||||||
|
'Number already exists for this link' => '该链接下已存在相同号码',
|
||||||
|
'Inserted %d number(s)' => '成功添加 %d 条号码',
|
||||||
|
'Skipped %d duplicate(s)' => '跳过 %d 条重复号码',
|
||||||
|
'Invalid status' => '状态无效',
|
||||||
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
|
'Batch update success' => '批量更新成功',
|
||||||
|
'Unit count' => '个',
|
||||||
|
];
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Ticket_type' => '工单类型',
|
||||||
|
'Ticket_name' => '工单名称',
|
||||||
|
'Ticket_url' => '工单链接',
|
||||||
|
'Ticket_total' => '工单总量',
|
||||||
|
'Split_link_id' => '分流链接',
|
||||||
|
'Number_type' => '号码类型',
|
||||||
|
'Number_type_custom' => '自定义号码类型',
|
||||||
|
'Start_time' => '开始时间',
|
||||||
|
'End_time' => '到期时间',
|
||||||
|
'Order_limit' => '单号上限',
|
||||||
|
'Assign_ratio' => '下号比率',
|
||||||
|
'Account' => '工单账号',
|
||||||
|
'Password' => '工单密码',
|
||||||
|
'Status' => '状态',
|
||||||
|
'Status normal' => '正常',
|
||||||
|
'Status hidden' => '停用',
|
||||||
|
'Complete_count' => '完成数量',
|
||||||
|
'Ticket_progress' => '工单进度',
|
||||||
|
'Inbound_ratio' => '进线比例',
|
||||||
|
'Speed_per_hour' => '当前速度',
|
||||||
|
'Number_count' => '号码数量',
|
||||||
|
'Number_count_detail' => '离线 %s / 封号 %s',
|
||||||
|
'Sync_status' => '同步状态',
|
||||||
|
'Sync status success' => '同步成功',
|
||||||
|
'Sync status error' => '同步异常',
|
||||||
|
'Sync status pending' => '待同步',
|
||||||
|
'Sync display success' => '同步成功 / 在线人数 %s',
|
||||||
|
'Sync display error' => '同步异常',
|
||||||
|
'Createtime' => '创建时间',
|
||||||
|
'Section basic' => '基础信息',
|
||||||
|
'Section time rule' => '时间与规则',
|
||||||
|
'Section account' => '账号信息',
|
||||||
|
'Number type custom' => '自定义',
|
||||||
|
'Ticket type xinghe' => '星河云控',
|
||||||
|
'Ticket type haiwang' => '海王',
|
||||||
|
'Ticket type taiji' => '太极云控',
|
||||||
|
'Ticket type huojian' => '火箭云控',
|
||||||
|
'Ticket type ss_channel'=> 'SS云控(Channel)',
|
||||||
|
'Ticket type ss_customer'=> 'SS云控(Customer)',
|
||||||
|
'Ticket type yifafa' => '译发发云控',
|
||||||
|
'Ticket type a2c' => 'A2C云控',
|
||||||
|
'Ticket type ceo_scrm' => 'CEO SCRM',
|
||||||
|
'Ticket type whatshub' => 'Whatshub云控',
|
||||||
|
'Ticket type sihai' => '四海云控',
|
||||||
|
'End time must after start' => '到期时间必须晚于开始时间',
|
||||||
|
'Please select' => '请选择',
|
||||||
|
];
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitPlatformDomainService;
|
||||||
|
use think\Db;
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流号码模型
|
||||||
|
*/
|
||||||
|
class Number extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'split_number';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
|
protected $createTime = 'createtime';
|
||||||
|
protected $updateTime = 'updatetime';
|
||||||
|
protected $deleteTime = false;
|
||||||
|
|
||||||
|
protected $append = [
|
||||||
|
'number_type_text',
|
||||||
|
'link_url_text',
|
||||||
|
'status_text',
|
||||||
|
'manual_manage_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码类型(与工单模块一致)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getNumberTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whatsapp' => 'WhatsApp',
|
||||||
|
'telegram' => 'Telegram',
|
||||||
|
'line' => 'Line',
|
||||||
|
'custom' => __('Number type custom'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'normal' => __('Status normal'),
|
||||||
|
'hidden' => __('Status hidden'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getManualManageList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Manual manage no'),
|
||||||
|
'1' => __('Manual manage yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联分流链接
|
||||||
|
*/
|
||||||
|
public function splitLink()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumberTypeCustomAttr($value): string
|
||||||
|
{
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setManualManageAttr($value): int
|
||||||
|
{
|
||||||
|
return (int) $value === 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumberTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$type = (string) ($data['number_type'] ?? '');
|
||||||
|
if ($type === 'custom') {
|
||||||
|
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||||
|
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||||
|
}
|
||||||
|
$list = $this->getNumberTypeList();
|
||||||
|
return $list[$type] ?? $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLinkUrlTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
if ($value !== '' && $value !== null) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$code = Link::where('id', $linkId)->value('link_code');
|
||||||
|
return self::buildLinkUrl((string) $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['status'] ?? '');
|
||||||
|
$list = $this->getStatusList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getManualManageTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['manual_manage'] ?? 0));
|
||||||
|
$list = $this->getManualManageList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据链接码生成完整分流 URL
|
||||||
|
*/
|
||||||
|
public static function buildLinkUrl(string $linkCode): string
|
||||||
|
{
|
||||||
|
$linkCode = trim($linkCode);
|
||||||
|
if ($linkCode === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = trim((string) \think\Config::get('site.split_platform_domain'));
|
||||||
|
if ($raw === '') {
|
||||||
|
$raw = trim((string) Db::name('config')->where('name', 'split_platform_domain')->value('value'));
|
||||||
|
}
|
||||||
|
$domains = SplitPlatformDomainService::parseList($raw);
|
||||||
|
$domain = $domains[0] ?? '';
|
||||||
|
if ($domain === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return 'https://' . $domain . '/s/' . $linkCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 textarea 号码列表
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function parseNumbersText(string $text): array
|
||||||
|
{
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
|
||||||
|
$numbers = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$num = trim((string) $line);
|
||||||
|
if ($num === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$numbers[$num] = $num;
|
||||||
|
}
|
||||||
|
return array_values($numbers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流工单模型
|
||||||
|
*/
|
||||||
|
class Ticket extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'split_ticket';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
|
protected $createTime = 'createtime';
|
||||||
|
protected $updateTime = 'updatetime';
|
||||||
|
protected $deleteTime = false;
|
||||||
|
|
||||||
|
protected $append = [
|
||||||
|
'ticket_type_text',
|
||||||
|
'number_type_text',
|
||||||
|
'link_code_text',
|
||||||
|
'start_time_text',
|
||||||
|
'end_time_text',
|
||||||
|
'status_text',
|
||||||
|
'click_count',
|
||||||
|
'ticket_progress_text',
|
||||||
|
'inbound_ratio_text',
|
||||||
|
'sync_display_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单类型
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getTicketTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'xinghe' => __('Ticket type xinghe'),
|
||||||
|
'haiwang' => __('Ticket type haiwang'),
|
||||||
|
'taiji' => __('Ticket type taiji'),
|
||||||
|
'huojian' => __('Ticket type huojian'),
|
||||||
|
'ss_channel' => __('Ticket type ss_channel'),
|
||||||
|
'ss_customer' => __('Ticket type ss_customer'),
|
||||||
|
'yifafa' => __('Ticket type yifafa'),
|
||||||
|
'a2c' => __('Ticket type a2c'),
|
||||||
|
'ceo_scrm' => __('Ticket type ceo_scrm'),
|
||||||
|
'whatshub' => __('Ticket type whatshub'),
|
||||||
|
'sihai' => __('Ticket type sihai'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'normal' => __('Status normal'),
|
||||||
|
'hidden' => __('Status hidden'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getSyncStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => __('Sync status success'),
|
||||||
|
'error' => __('Sync status error'),
|
||||||
|
'pending' => __('Sync status pending'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码类型
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getNumberTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whatsapp' => 'Whatsapp',
|
||||||
|
'telegram' => 'Telegram',
|
||||||
|
'line' => 'Line',
|
||||||
|
'custom' => __('Number type custom'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联分流链接
|
||||||
|
*/
|
||||||
|
public function splitLink()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表子查询注入的点击数,无则按关联号码汇总
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getClickCountAttr($value, $data): int
|
||||||
|
{
|
||||||
|
if (isset($data['click_count']) && $data['click_count'] !== '' && $data['click_count'] !== null) {
|
||||||
|
return (int) $data['click_count'];
|
||||||
|
}
|
||||||
|
return self::sumVisitCountForTicket($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单进度:完成数量 / 工单总量
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getTicketProgressTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$total = (int) ($data['ticket_total'] ?? 0);
|
||||||
|
$complete = (int) ($data['complete_count'] ?? 0);
|
||||||
|
if ($total <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$percent = round($complete / $total * 100, 2);
|
||||||
|
return $percent . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进线比例:进线人数 / 点击数(点击数=关联号码 visit_count 之和)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getInboundRatioTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$clicks = $this->getClickCountAttr(null, $data);
|
||||||
|
$inbound = (int) ($data['inbound_count'] ?? 0);
|
||||||
|
if ($clicks <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$percent = round($inbound / $clicks * 100, 2);
|
||||||
|
return $percent . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
|
if ($status === 'success') {
|
||||||
|
return sprintf((string) __('Sync display success'), $online);
|
||||||
|
}
|
||||||
|
return (string) __('Sync display error');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartTimeAttr($value): ?int
|
||||||
|
{
|
||||||
|
return self::parseTimeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndTimeAttr($value): ?int
|
||||||
|
{
|
||||||
|
return self::parseTimeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumberTypeCustomAttr($value): string
|
||||||
|
{
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTicketTotalAttr($value): int
|
||||||
|
{
|
||||||
|
return max(0, (int) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTicketTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['ticket_type'] ?? '');
|
||||||
|
$list = $this->getTicketTypeList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumberTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$type = (string) ($data['number_type'] ?? '');
|
||||||
|
if ($type === 'custom') {
|
||||||
|
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||||
|
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||||
|
}
|
||||||
|
$list = $this->getNumberTypeList();
|
||||||
|
return $list[$type] ?? $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLinkCodeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
if ($value !== '' && $value !== null) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$code = Link::where('id', $linkId)->value('link_code');
|
||||||
|
return (string) $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartTimeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
return self::formatTimeText($data['start_time'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndTimeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
return self::formatTimeText($data['end_time'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['status'] ?? '');
|
||||||
|
$list = $this->getStatusList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建列表用 click_count 子查询 SQL 片段
|
||||||
|
*/
|
||||||
|
public static function buildClickCountSubQuery(string $ticketTableAlias = ''): string
|
||||||
|
{
|
||||||
|
$ticketTable = (new self())->getTable();
|
||||||
|
$numberTable = (new Number())->getTable();
|
||||||
|
$t = $ticketTableAlias !== '' ? $ticketTableAlias : $ticketTable;
|
||||||
|
return "(SELECT COALESCE(SUM(n.visit_count), 0) FROM `{$numberTable}` n "
|
||||||
|
. "WHERE n.ticket_name = `{$t}`.ticket_name "
|
||||||
|
. "AND n.split_link_id = `{$t}`.split_link_id "
|
||||||
|
. "AND n.admin_id = `{$t}`.admin_id) AS click_count";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private static function sumVisitCountForTicket(array $data): int
|
||||||
|
{
|
||||||
|
$ticketName = (string) ($data['ticket_name'] ?? '');
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
$adminId = (int) ($data['admin_id'] ?? 0);
|
||||||
|
if ($ticketName === '' || $linkId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$sum = Db::name('split_number')
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('admin_id', $adminId)
|
||||||
|
->sum('visit_count');
|
||||||
|
return (int) $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function parseTimeValue($value): ?int
|
||||||
|
{
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
$ts = strtotime((string) $value);
|
||||||
|
return $ts === false ? null : $ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function formatTimeText($value): string
|
||||||
|
{
|
||||||
|
if ($value === '' || $value === null || !is_numeric($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$ts = (int) $value;
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\validate\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link;
|
||||||
|
use app\admin\model\split\Number as NumberModel;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流号码验证器(部署源文件,由 deploy_split_number.php 复制到 application)
|
||||||
|
*/
|
||||||
|
class Number extends Validate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'number_type' => 'require|checkNumberType',
|
||||||
|
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||||
|
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||||
|
'ticket_name' => 'require|max:100',
|
||||||
|
'numbers' => 'require|checkNumbers',
|
||||||
|
'number' => 'require|max:50',
|
||||||
|
'status' => 'require|checkStatus',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'number_type.require' => '请选择号码类型',
|
||||||
|
'split_link_id.require' => '请选择分流链接',
|
||||||
|
'ticket_name.require' => '请填写工单名称',
|
||||||
|
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||||
|
'numbers.require' => '请填写号码',
|
||||||
|
'number.require' => '请填写号码',
|
||||||
|
'number.max' => '号码不能超过50个字符',
|
||||||
|
'status.require' => '请选择状态',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $scene = [
|
||||||
|
'add' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'numbers', 'status'],
|
||||||
|
'edit' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'number', 'status'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new NumberModel())->getNumberTypeList();
|
||||||
|
return isset($list[$key]) ? true : '号码类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
if (($data['number_type'] ?? '') !== 'custom') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trim((string) $value) === '') {
|
||||||
|
return '请填写自定义号码类型';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkSplitLink($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$linkId = (int) $value;
|
||||||
|
$link = Link::get($linkId);
|
||||||
|
if (!$link) {
|
||||||
|
return '所选分流链接不存在';
|
||||||
|
}
|
||||||
|
if (($link['status'] ?? '') !== 'normal') {
|
||||||
|
return '所选分流链接已停用';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumbers($value)
|
||||||
|
{
|
||||||
|
$list = NumberModel::parseNumbersText((string) $value);
|
||||||
|
return $list !== [] ? true : '请至少填写一个有效号码(每行一个)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkStatus($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new NumberModel())->getStatusList();
|
||||||
|
return isset($list[$key]) ? true : '状态无效';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\validate\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link;
|
||||||
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流工单验证器(部署源文件,由 deploy_split_ticket.php 复制到 application)
|
||||||
|
*/
|
||||||
|
class Ticket extends Validate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'ticket_type' => 'require|checkTicketType',
|
||||||
|
'ticket_name' => 'require|max:100',
|
||||||
|
'ticket_url' => 'require|max:1000',
|
||||||
|
'ticket_total' => 'require|integer|egt:0',
|
||||||
|
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||||
|
'number_type' => 'require|checkNumberType',
|
||||||
|
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||||
|
'start_time' => 'require|checkStartTime',
|
||||||
|
'end_time' => 'require|checkEndTime',
|
||||||
|
'order_limit' => 'require|integer|egt:0',
|
||||||
|
'assign_ratio' => 'require|integer|egt:0',
|
||||||
|
'account' => 'max:50',
|
||||||
|
'password' => 'max:50',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'ticket_type.require' => '请选择工单类型',
|
||||||
|
'ticket_name.require' => '请填写工单名称',
|
||||||
|
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||||
|
'ticket_url.require' => '请填写工单链接',
|
||||||
|
'ticket_url.max' => '工单链接不能超过1000个字符',
|
||||||
|
'ticket_total.require' => '请填写工单总量',
|
||||||
|
'ticket_total.integer' => '工单总量必须为整数',
|
||||||
|
'ticket_total.egt' => '工单总量不能小于0',
|
||||||
|
'split_link_id.require' => '请选择分流链接',
|
||||||
|
'number_type.require' => '请选择号码类型',
|
||||||
|
'start_time.require' => '请选择开始时间',
|
||||||
|
'end_time.require' => '请选择到期时间',
|
||||||
|
'order_limit.require' => '请填写单号上限',
|
||||||
|
'order_limit.egt' => '单号上限不能小于0',
|
||||||
|
'assign_ratio.require' => '请填写下号比率',
|
||||||
|
'assign_ratio.egt' => '下号比率不能小于0',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $scene = [
|
||||||
|
'add' => [
|
||||||
|
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||||
|
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||||
|
'order_limit', 'assign_ratio', 'account', 'password',
|
||||||
|
],
|
||||||
|
'edit' => [
|
||||||
|
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||||
|
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||||
|
'order_limit', 'assign_ratio', 'account', 'password',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkTicketType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new TicketModel())->getTicketTypeList();
|
||||||
|
return isset($list[$key]) ? true : '工单类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new TicketModel())->getNumberTypeList();
|
||||||
|
return isset($list[$key]) ? true : '号码类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
if (($data['number_type'] ?? '') !== 'custom') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trim((string) $value) === '') {
|
||||||
|
return '请填写自定义号码类型';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkSplitLink($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$linkId = (int) $value;
|
||||||
|
$link = Link::get($linkId);
|
||||||
|
if (!$link) {
|
||||||
|
return '所选分流链接不存在';
|
||||||
|
}
|
||||||
|
if (($link['status'] ?? '') !== 'normal') {
|
||||||
|
return '所选分流链接已停用';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkStartTime($value)
|
||||||
|
{
|
||||||
|
$ts = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||||
|
return ($ts !== false && $ts > 0) ? true : '开始时间格式无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkEndTime($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$endTs = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||||
|
if ($endTs === false || $endTs <= 0) {
|
||||||
|
return '到期时间格式无效';
|
||||||
|
}
|
||||||
|
$startRaw = $data['start_time'] ?? '';
|
||||||
|
$startTs = is_numeric($startRaw) ? (int) $startRaw : strtotime((string) $startRaw);
|
||||||
|
if ($startTs === false || $startTs <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($endTs <= $startTs) {
|
||||||
|
return '到期时间必须晚于开始时间';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<form id="add-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
{include file="split/number/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<form id="edit-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||||
|
{include file="split/number/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<style>
|
||||||
|
.split-number-form .panel {
|
||||||
|
border-color: #e8ecf1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .panel-heading {
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .panel-body {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
}
|
||||||
|
.split-number-form .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .control-label {
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.split-number-form .control-label .text-danger {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.split-number-form .form-control {
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.split-number-form .st-grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.split-number-form .radio-inline {
|
||||||
|
margin-right: 18px;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer > .control-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer > div[class*="col-"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer .btn {
|
||||||
|
min-width: 100px;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section basic')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
{foreach name="numberTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="splitLinkList" item="link"}
|
||||||
|
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
{if isset($row)}
|
||||||
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
|
{else}
|
||||||
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
<textarea id="c-numbers" data-rule="required" class="form-control" name="row[numbers]" rows="8" placeholder="{:__('Numbers placeholder')}"></textarea>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label">{:__('Status')}<span class="text-danger">*</span></label>
|
||||||
|
<div>
|
||||||
|
{foreach name="statusList" item="vo" key="key"}
|
||||||
|
<label class="radio-inline">
|
||||||
|
<input type="radio" name="row[status]" value="{$key|htmlentities}" data-rule="required" {if isset($row) && $row.status==$key}checked{elseif !isset($row) && $key=='normal'/}checked{/if}> {$vo|htmlentities}
|
||||||
|
</label>
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="panel panel-default panel-intro">
|
||||||
|
<div class="panel-heading">
|
||||||
|
{:build_heading(null,FALSE)}
|
||||||
|
<ul class="nav nav-tabs" data-field="status">
|
||||||
|
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
|
||||||
|
{foreach name="statusList" item="vo" key="key"}
|
||||||
|
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key|htmlentities}" data-toggle="tab">{$vo|htmlentities}</a></li>
|
||||||
|
{/foreach}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="myTabContent" class="tab-content">
|
||||||
|
<div class="tab-pane fade active in" id="one">
|
||||||
|
<div class="widget-body no-padding">
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.number/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
</div>
|
||||||
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
data-operate-edit="{:$auth->check('split.number/edit')}"
|
||||||
|
data-operate-del="{:$auth->check('split.number/del')}"
|
||||||
|
width="100%">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<form id="add-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
{include file="split/ticket/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<form id="edit-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||||
|
{include file="split/ticket/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<style>
|
||||||
|
.split-ticket-form .panel {
|
||||||
|
border-color: #e8ecf1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .panel-heading {
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .panel-body {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .control-label {
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.split-ticket-form .control-label .text-danger {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-control {
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-control:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
/* 单列整行 */
|
||||||
|
.split-ticket-form .st-grid-1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
/* 双列:PC 端 column-gap 控制列间距(不依赖 Bootstrap col padding) */
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
column-gap: 0;
|
||||||
|
row-gap: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.split-ticket-form .st-grid-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
.split-ticket-form .st-grid-2 .st-grid-cell + .st-grid-cell .form-group {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 底部按钮区(兼容 layer 弹窗 footer 迁移) */
|
||||||
|
.split-ticket-form > .layer-footer {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer > .control-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer > div[class*="col-"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: none;
|
||||||
|
float: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn {
|
||||||
|
min-width: 100px;
|
||||||
|
margin: 0 12px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary:hover,
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary:focus {
|
||||||
|
background-color: #2563eb;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-default {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-default:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section basic')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_type" class="control-label">{:__('Ticket_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-ticket_type" name="row[ticket_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="ticketTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.ticket_type==$key}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_url" class="control-label">{:__('Ticket_url')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_url" data-rule="required" class="form-control" name="row[ticket_url]" type="text" value="{$row.ticket_url|default=''|htmlentities}" maxlength="1000" placeholder="https://">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_total" class="control-label">{:__('Ticket_total')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_total" data-rule="required" class="form-control" name="row[ticket_total]" type="number" min="0" step="1" value="{$row.ticket_total|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="splitLinkList" item="link"}
|
||||||
|
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required">
|
||||||
|
{foreach name="numberTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger split-required-star">*</span></label>
|
||||||
|
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section time rule')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-start_time" class="control-label">{:__('Start_time')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-start_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[start_time]" type="text" value="{$row.start_time|default=''|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-end_time" class="control-label">{:__('End_time')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-end_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[end_time]" type="text" value="{$row.end_time|default=''|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-order_limit" class="control-label">{:__('Order_limit')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-order_limit" data-rule="required;integer;range(0~)" class="form-control" name="row[order_limit]" type="number" min="0" step="1" value="{$row.order_limit|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-assign_ratio" class="control-label">{:__('Assign_ratio')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-assign_ratio" data-rule="required;integer;range(0~)" class="form-control" name="row[assign_ratio]" type="number" min="0" step="1" value="{$row.assign_ratio|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section account')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-account" class="control-label">{:__('Account')}</label>
|
||||||
|
<input id="c-account" class="form-control" name="row[account]" type="text" value="{$row.account|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-password" class="control-label">{:__('Password')}</label>
|
||||||
|
<input id="c-password" class="form-control" name="row[password]" type="text" value="{$row.password|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="panel panel-default panel-intro">
|
||||||
|
<div class="panel-heading">
|
||||||
|
{:build_heading(null,FALSE)}
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="myTabContent" class="tab-content">
|
||||||
|
<div class="tab-pane fade active in" id="one">
|
||||||
|
<div class="widget-body no-padding">
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.ticket/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.ticket/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
|
</div>
|
||||||
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
||||||
|
data-operate-del="{:$auth->check('split.ticket/del')}"
|
||||||
|
width="100%">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 部署分流管理-号码管理模块(需 root)
|
||||||
|
set -e
|
||||||
|
BASE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "请使用 root 执行: sudo bash $0"
|
||||||
|
echo "或: sudo php $BASE/runtime/deploy_split_number.php"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
php "$BASE/runtime/deploy_split_number.php"
|
||||||
|
echo "请在数据库执行 split_number.sql(若尚未执行),并在权限管理中勾选「分流管理 → 号码管理」。"
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 部署分流管理-工单管理模块(需 root)
|
||||||
|
set -e
|
||||||
|
BASE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "请使用 root 执行: sudo bash $0"
|
||||||
|
echo "或: sudo php $BASE/runtime/deploy_split_ticket.php"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
php "$BASE/runtime/deploy_split_ticket.php"
|
||||||
|
echo "请在数据库执行 split_ticket.sql(若尚未执行),并在权限管理中勾选「分流管理 → 工单管理」。"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- 分流管理 - 号码管理表与菜单权限
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `fa_split_number`;
|
||||||
|
CREATE TABLE `fa_split_number` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '号码ID',
|
||||||
|
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||||
|
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||||
|
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||||
|
`number` varchar(50) NOT NULL DEFAULT '' COMMENT '号码',
|
||||||
|
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||||
|
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||||
|
`visit_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '访问次数',
|
||||||
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数',
|
||||||
|
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是',
|
||||||
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_link_number` (`split_link_id`,`number`),
|
||||||
|
KEY `admin_id` (`admin_id`),
|
||||||
|
KEY `split_link_id` (`split_link_id`),
|
||||||
|
KEY `status` (`status`),
|
||||||
|
KEY `number` (`number`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流号码表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', r.id, 'split.number', '号码管理', 'fa fa-phone', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` r
|
||||||
|
WHERE r.name = 'split' AND r.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/index' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/add', '添加', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/add' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/edit', '编辑', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/edit' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/del', '删除', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/del' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/batchupdate', '批量更新号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/multi' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\validate\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link;
|
||||||
|
use app\admin\model\split\Number as NumberModel;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流号码验证器(部署源文件,由 deploy_split_number.php 复制到 application)
|
||||||
|
*/
|
||||||
|
class Number extends Validate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'number_type' => 'require|checkNumberType',
|
||||||
|
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||||
|
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||||
|
'ticket_name' => 'require|max:100',
|
||||||
|
'numbers' => 'require|checkNumbers',
|
||||||
|
'number' => 'require|max:50',
|
||||||
|
'status' => 'require|checkStatus',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'number_type.require' => '请选择号码类型',
|
||||||
|
'split_link_id.require' => '请选择分流链接',
|
||||||
|
'ticket_name.require' => '请填写工单名称',
|
||||||
|
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||||
|
'numbers.require' => '请填写号码',
|
||||||
|
'number.require' => '请填写号码',
|
||||||
|
'number.max' => '号码不能超过50个字符',
|
||||||
|
'status.require' => '请选择状态',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $scene = [
|
||||||
|
'add' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'numbers', 'status'],
|
||||||
|
'edit' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'number', 'status'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new NumberModel())->getNumberTypeList();
|
||||||
|
return isset($list[$key]) ? true : '号码类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
if (($data['number_type'] ?? '') !== 'custom') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trim((string) $value) === '') {
|
||||||
|
return '请填写自定义号码类型';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkSplitLink($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$linkId = (int) $value;
|
||||||
|
$link = Link::get($linkId);
|
||||||
|
if (!$link) {
|
||||||
|
return '所选分流链接不存在';
|
||||||
|
}
|
||||||
|
if (($link['status'] ?? '') !== 'normal') {
|
||||||
|
return '所选分流链接已停用';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumbers($value)
|
||||||
|
{
|
||||||
|
$list = NumberModel::parseNumbersText((string) $value);
|
||||||
|
return $list !== [] ? true : '请至少填写一个有效号码(每行一个)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkStatus($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new NumberModel())->getStatusList();
|
||||||
|
return isset($list[$key]) ? true : '状态无效';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- 分流管理 - 工单管理表与菜单权限
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `fa_split_ticket`;
|
||||||
|
CREATE TABLE `fa_split_ticket` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||||
|
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||||
|
`ticket_type` varchar(32) NOT NULL DEFAULT '' COMMENT '工单类型',
|
||||||
|
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||||
|
`ticket_url` varchar(1000) NOT NULL DEFAULT '' COMMENT '工单链接',
|
||||||
|
`ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量',
|
||||||
|
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||||
|
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||||
|
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||||
|
`start_time` bigint(16) DEFAULT NULL COMMENT '开始时间',
|
||||||
|
`end_time` bigint(16) DEFAULT NULL COMMENT '到期时间',
|
||||||
|
`order_limit` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单号上限',
|
||||||
|
`assign_ratio` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率',
|
||||||
|
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
||||||
|
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
||||||
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
||||||
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
||||||
|
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
||||||
|
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
||||||
|
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
||||||
|
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
||||||
|
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
||||||
|
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
||||||
|
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
||||||
|
`sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要',
|
||||||
|
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `admin_id` (`admin_id`),
|
||||||
|
KEY `split_link_id` (`split_link_id`),
|
||||||
|
KEY `ticket_type` (`ticket_type`),
|
||||||
|
KEY `start_time` (`start_time`),
|
||||||
|
KEY `end_time` (`end_time`),
|
||||||
|
KEY `status` (`status`),
|
||||||
|
KEY `sync_status` (`sync_status`),
|
||||||
|
KEY `sync_time` (`sync_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流工单表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- 菜单:工单管理(父级 split)
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', r.id, 'split.ticket', '工单管理', 'fa fa-ticket', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` r
|
||||||
|
WHERE r.name = 'split' AND r.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/index' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/add', '添加', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/add' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/edit', '编辑', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/edit' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/del', '删除', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/del' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.ticket/multi', '批量更新', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/multi' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 工单表补充 status 字段(列表开关依赖此列)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用' AFTER `password`,
|
||||||
|
ADD KEY `status` (`status`);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 工单表:统计字段 + ticket_total 整数化(供列表展示与后续同步接口写入)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1. 同步统计字段(若已存在则跳过需手工处理)
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)' AFTER `status`,
|
||||||
|
ADD COLUMN `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)' AFTER `complete_count`,
|
||||||
|
ADD COLUMN `speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)' AFTER `inbound_count`,
|
||||||
|
ADD COLUMN `number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)' AFTER `speed_per_hour`,
|
||||||
|
ADD COLUMN `number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)' AFTER `number_count`,
|
||||||
|
ADD COLUMN `number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)' AFTER `number_offline_count`,
|
||||||
|
ADD COLUMN `online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)' AFTER `number_banned_count`,
|
||||||
|
ADD COLUMN `sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态' AFTER `online_count`,
|
||||||
|
ADD COLUMN `sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间' AFTER `sync_status`,
|
||||||
|
ADD COLUMN `sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要' AFTER `sync_time`;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD KEY `sync_status` (`sync_status`),
|
||||||
|
ADD KEY `sync_time` (`sync_time`);
|
||||||
|
|
||||||
|
-- 2. ticket_total:varchar -> int unsigned(非数字旧值变为 0)
|
||||||
|
UPDATE `fa_split_ticket` SET `ticket_total` = '0' WHERE `ticket_total` REGEXP '[^0-9]' OR `ticket_total` = '';
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量';
|
||||||
|
|
||||||
|
-- 3. 号码表组合索引:加速按工单汇总 visit_count
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD KEY `idx_ticket_agg` (`admin_id`, `split_link_id`, `ticket_name`(50));
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\validate\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link;
|
||||||
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流工单验证器(部署源文件,由 deploy_split_ticket.php 复制到 application)
|
||||||
|
*/
|
||||||
|
class Ticket extends Validate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'ticket_type' => 'require|checkTicketType',
|
||||||
|
'ticket_name' => 'require|max:100',
|
||||||
|
'ticket_url' => 'require|max:1000',
|
||||||
|
'ticket_total' => 'require|integer|egt:0',
|
||||||
|
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||||
|
'number_type' => 'require|checkNumberType',
|
||||||
|
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||||
|
'start_time' => 'require|checkStartTime',
|
||||||
|
'end_time' => 'require|checkEndTime',
|
||||||
|
'order_limit' => 'require|integer|egt:0',
|
||||||
|
'assign_ratio' => 'require|integer|egt:0',
|
||||||
|
'account' => 'max:50',
|
||||||
|
'password' => 'max:50',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'ticket_type.require' => '请选择工单类型',
|
||||||
|
'ticket_name.require' => '请填写工单名称',
|
||||||
|
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||||
|
'ticket_url.require' => '请填写工单链接',
|
||||||
|
'ticket_url.max' => '工单链接不能超过1000个字符',
|
||||||
|
'ticket_total.require' => '请填写工单总量',
|
||||||
|
'ticket_total.integer' => '工单总量必须为整数',
|
||||||
|
'ticket_total.egt' => '工单总量不能小于0',
|
||||||
|
'split_link_id.require' => '请选择分流链接',
|
||||||
|
'number_type.require' => '请选择号码类型',
|
||||||
|
'start_time.require' => '请选择开始时间',
|
||||||
|
'end_time.require' => '请选择到期时间',
|
||||||
|
'order_limit.require' => '请填写单号上限',
|
||||||
|
'order_limit.egt' => '单号上限不能小于0',
|
||||||
|
'assign_ratio.require' => '请填写下号比率',
|
||||||
|
'assign_ratio.egt' => '下号比率不能小于0',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $scene = [
|
||||||
|
'add' => [
|
||||||
|
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||||
|
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||||
|
'order_limit', 'assign_ratio', 'account', 'password',
|
||||||
|
],
|
||||||
|
'edit' => [
|
||||||
|
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||||
|
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||||
|
'order_limit', 'assign_ratio', 'account', 'password',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkTicketType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new TicketModel())->getTicketTypeList();
|
||||||
|
return isset($list[$key]) ? true : '工单类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberType($value)
|
||||||
|
{
|
||||||
|
$key = (string) $value;
|
||||||
|
$list = (new TicketModel())->getNumberTypeList();
|
||||||
|
return isset($list[$key]) ? true : '号码类型无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
if (($data['number_type'] ?? '') !== 'custom') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trim((string) $value) === '') {
|
||||||
|
return '请填写自定义号码类型';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkSplitLink($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$linkId = (int) $value;
|
||||||
|
$link = Link::get($linkId);
|
||||||
|
if (!$link) {
|
||||||
|
return '所选分流链接不存在';
|
||||||
|
}
|
||||||
|
if (($link['status'] ?? '') !== 'normal') {
|
||||||
|
return '所选分流链接已停用';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkStartTime($value)
|
||||||
|
{
|
||||||
|
$ts = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||||
|
return ($ts !== false && $ts > 0) ? true : '开始时间格式无效';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @param mixed $rule
|
||||||
|
* @param array $data
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkEndTime($value, $rule, array $data = [])
|
||||||
|
{
|
||||||
|
$endTs = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||||
|
if ($endTs === false || $endTs <= 0) {
|
||||||
|
return '到期时间格式无效';
|
||||||
|
}
|
||||||
|
$startRaw = $data['start_time'] ?? '';
|
||||||
|
$startTs = is_numeric($startRaw) ? (int) $startRaw : strtotime((string) $startRaw);
|
||||||
|
if ($startTs === false || $startTs <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($endTs <= $startTs) {
|
||||||
|
return '到期时间必须晚于开始时间';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link as LinkModel;
|
||||||
|
use app\admin\model\split\Number as NumberModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\DbException;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
use think\response\Json;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-phone
|
||||||
|
* @remark 分流号码管理,关联分流链接
|
||||||
|
*/
|
||||||
|
class Number extends Backend
|
||||||
|
{
|
||||||
|
/** @var NumberModel */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'ticket_name,number';
|
||||||
|
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
|
||||||
|
protected $modelValidate = true;
|
||||||
|
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
protected $noNeedRight = ['script'];
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/number/';
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
|
||||||
|
$lang = $this->request->langset();
|
||||||
|
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||||
|
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/number.php';
|
||||||
|
if (is_file($langFile)) {
|
||||||
|
\think\Lang::load($langFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->model = new NumberModel();
|
||||||
|
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->view->assign('statusList', $this->model->getStatusList());
|
||||||
|
$this->view->assign('manualManageList', $this->model->getManualManageList());
|
||||||
|
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||||
|
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
|
|
||||||
|
$this->setupPatchFrontend();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupPatchFrontend(): void
|
||||||
|
{
|
||||||
|
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||||
|
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||||
|
$usePatchJs = is_file($patchJs) && (
|
||||||
|
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||||
|
);
|
||||||
|
if (!$usePatchJs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||||
|
$version = (string) \think\Config::get('site.version');
|
||||||
|
$scriptUrl = (string) url('split.number/script', ['v' => $version], false, true);
|
||||||
|
if (strpos($scriptUrl, '?') === false) {
|
||||||
|
$scriptUrl .= '?v=' . $version;
|
||||||
|
}
|
||||||
|
if (strpos($scriptUrl, '://') === false) {
|
||||||
|
$scriptUrl = $this->request->domain() . $scriptUrl;
|
||||||
|
}
|
||||||
|
$cfg['jsname'] = $scriptUrl;
|
||||||
|
$this->view->assign('config', $cfg);
|
||||||
|
$this->view->config = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
private function buildSplitLinkList(): array
|
||||||
|
{
|
||||||
|
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[] = [
|
||||||
|
'id' => (string) $row['id'],
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPatch(string $template): string
|
||||||
|
{
|
||||||
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
|
$appFile = APP_PATH . 'admin/view/split/number/' . $template . '.html';
|
||||||
|
if (is_file($patchFile)) {
|
||||||
|
$file = $patchFile;
|
||||||
|
} elseif (is_file($appFile)) {
|
||||||
|
$file = $appFile;
|
||||||
|
} else {
|
||||||
|
$this->error('模板文件不存在');
|
||||||
|
}
|
||||||
|
return (string) $this->view->fetch($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|Json
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->request->filter(['strip_tags', 'trim']);
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
return $this->fetchPatch('index');
|
||||||
|
}
|
||||||
|
if ($this->request->request('keyField')) {
|
||||||
|
return $this->selectpage();
|
||||||
|
}
|
||||||
|
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||||
|
$list = $this->model
|
||||||
|
->with(['splitLink'])
|
||||||
|
->where($where)
|
||||||
|
->order($sort, $order)
|
||||||
|
->paginate($limit);
|
||||||
|
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||||
|
return json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->fetchPatch('add');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbersText = (string) ($params['numbers'] ?? '');
|
||||||
|
unset($params['numbers']);
|
||||||
|
$numberList = NumberModel::parseNumbersText($numbersText);
|
||||||
|
if ($numberList === []) {
|
||||||
|
$this->error(__('Please fill at least one number'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$validateData = array_merge($params, ['numbers' => $numbersText]);
|
||||||
|
$adminId = $this->dataLimit && $this->dataLimitFieldAutoFill ? (int) $this->auth->id : 0;
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate, $validateData);
|
||||||
|
}
|
||||||
|
$splitLinkId = (int) ($params['split_link_id'] ?? 0);
|
||||||
|
$baseRow = [
|
||||||
|
'split_link_id' => $splitLinkId,
|
||||||
|
'ticket_name' => (string) ($params['ticket_name'] ?? ''),
|
||||||
|
'number_type' => (string) ($params['number_type'] ?? ''),
|
||||||
|
'number_type_custom' => (string) ($params['number_type_custom'] ?? ''),
|
||||||
|
'status' => (string) ($params['status'] ?? 'normal'),
|
||||||
|
'visit_count' => 0,
|
||||||
|
'inbound_count' => 0,
|
||||||
|
'manual_manage' => 0,
|
||||||
|
];
|
||||||
|
if ($adminId > 0) {
|
||||||
|
$baseRow['admin_id'] = $adminId;
|
||||||
|
}
|
||||||
|
foreach ($numberList as $num) {
|
||||||
|
$exists = $this->model
|
||||||
|
->where('split_link_id', $splitLinkId)
|
||||||
|
->where('number', $num)
|
||||||
|
->find();
|
||||||
|
if ($exists) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row = $baseRow;
|
||||||
|
$row['number'] = $num;
|
||||||
|
$item = new NumberModel();
|
||||||
|
$item->allowField(true)->isUpdate(false)->save($row);
|
||||||
|
$inserted++;
|
||||||
|
}
|
||||||
|
if ($inserted === 0) {
|
||||||
|
throw new Exception(__('All numbers already exist for this link'));
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = __('Inserted %d number(s)', $inserted);
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
||||||
|
}
|
||||||
|
$this->success($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $ids
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->view->assign('row', $row);
|
||||||
|
return $this->fetchPatch('edit');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$newNumber = trim((string) ($params['number'] ?? ''));
|
||||||
|
$splitLinkId = (int) ($params['split_link_id'] ?? $row['split_link_id']);
|
||||||
|
$exists = $this->model
|
||||||
|
->where('split_link_id', $splitLinkId)
|
||||||
|
->where('number', $newNumber)
|
||||||
|
->where('id', '<>', (int) $row['id'])
|
||||||
|
->find();
|
||||||
|
if ($exists) {
|
||||||
|
$this->error(__('Number already exists for this link'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||||
|
$row->validateFailException()->validate($validate, $params);
|
||||||
|
}
|
||||||
|
$result = $row->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新号码状态与手动管理
|
||||||
|
*/
|
||||||
|
public function batchupdate(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
$ids = $this->request->post('ids', '');
|
||||||
|
if ($ids === '' || $ids === null) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
if (!is_array($ids)) {
|
||||||
|
$ids = explode(',', (string) $ids);
|
||||||
|
}
|
||||||
|
$ids = array_filter(array_map('intval', $ids));
|
||||||
|
if ($ids === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) $this->request->post('status', '');
|
||||||
|
$manualManage = $this->request->post('manual_manage', '');
|
||||||
|
$statusList = $this->model->getStatusList();
|
||||||
|
if (!isset($statusList[$status])) {
|
||||||
|
$this->error(__('Invalid status'));
|
||||||
|
}
|
||||||
|
if (!in_array((string) $manualManage, ['0', '1'], true)) {
|
||||||
|
$this->error(__('Invalid manual manage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->model->where('id', 'in', $ids);
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$count = $query->update([
|
||||||
|
'status' => $status,
|
||||||
|
'manual_manage' => (int) $manualManage,
|
||||||
|
]);
|
||||||
|
if ($count === false || $count === 0) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success(__('Batch update success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排除不可由表单提交的字段
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function preExcludeFields($params): array
|
||||||
|
{
|
||||||
|
$params = parent::preExcludeFields($params);
|
||||||
|
unset(
|
||||||
|
$params['visit_count'],
|
||||||
|
$params['inbound_count'],
|
||||||
|
$params['manual_manage'],
|
||||||
|
$params['id']
|
||||||
|
);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function script(): void
|
||||||
|
{
|
||||||
|
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||||
|
}
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$this->error('脚本文件不存在');
|
||||||
|
}
|
||||||
|
$content = file_get_contents($jsFile);
|
||||||
|
if ($content === false) {
|
||||||
|
$this->error('读取脚本失败');
|
||||||
|
}
|
||||||
|
$response = response($content, 200, [
|
||||||
|
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control' => 'public, max-age=3600',
|
||||||
|
]);
|
||||||
|
throw new \think\exception\HttpResponseException($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\split;
|
||||||
|
|
||||||
|
use app\admin\model\split\Link as LinkModel;
|
||||||
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\DbException;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
use think\response\Json;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-ticket
|
||||||
|
* @remark 分流工单管理,关联分流链接
|
||||||
|
*/
|
||||||
|
class Ticket extends Backend
|
||||||
|
{
|
||||||
|
/** @var \app\admin\model\split\Ticket */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'ticket_name,ticket_url,ticket_total';
|
||||||
|
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
|
||||||
|
protected $modelValidate = true;
|
||||||
|
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
/** @var string[] 无需鉴权的方法 */
|
||||||
|
protected $noNeedRight = ['script'];
|
||||||
|
|
||||||
|
/** @var string patches 视图目录 */
|
||||||
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
|
||||||
|
$lang = $this->request->langset();
|
||||||
|
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||||
|
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php';
|
||||||
|
if (is_file($langFile)) {
|
||||||
|
\think\Lang::load($langFile);
|
||||||
|
}
|
||||||
|
$linkLangFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/link.php';
|
||||||
|
if (is_file($linkLangFile)) {
|
||||||
|
\think\Lang::load($linkLangFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->model = new \app\admin\model\split\Ticket();
|
||||||
|
$this->view->assign('ticketTypeList', $this->model->getTicketTypeList());
|
||||||
|
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->view->assign('statusList', $this->model->getStatusList());
|
||||||
|
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||||
|
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
|
||||||
|
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||||
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
|
||||||
|
$this->setupPatchFrontend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未部署 JS 时指向 script 接口
|
||||||
|
*/
|
||||||
|
private function setupPatchFrontend(): void
|
||||||
|
{
|
||||||
|
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||||
|
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||||
|
$usePatchJs = is_file($patchJs) && (
|
||||||
|
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||||
|
);
|
||||||
|
if (!$usePatchJs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||||
|
$version = (string) \think\Config::get('site.version');
|
||||||
|
$scriptUrl = (string) url('split.ticket/script', ['v' => $version], false, true);
|
||||||
|
if (strpos($scriptUrl, '?') === false) {
|
||||||
|
$scriptUrl .= '?v=' . $version;
|
||||||
|
}
|
||||||
|
if (strpos($scriptUrl, '://') === false) {
|
||||||
|
$scriptUrl = $this->request->domain() . $scriptUrl;
|
||||||
|
}
|
||||||
|
$cfg['jsname'] = $scriptUrl;
|
||||||
|
$this->view->assign('config', $cfg);
|
||||||
|
$this->view->config = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
private function buildSplitLinkList(): array
|
||||||
|
{
|
||||||
|
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[] = [
|
||||||
|
'id' => (string) $row['id'],
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPatch(string $template): string
|
||||||
|
{
|
||||||
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
|
$appFile = APP_PATH . 'admin/view/split/ticket/' . $template . '.html';
|
||||||
|
if (is_file($patchFile)) {
|
||||||
|
$file = $patchFile;
|
||||||
|
} elseif (is_file($appFile)) {
|
||||||
|
$file = $appFile;
|
||||||
|
} else {
|
||||||
|
$this->error('模板文件不存在');
|
||||||
|
}
|
||||||
|
return (string) $this->view->fetch($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|Json
|
||||||
|
* @throws DbException
|
||||||
|
* @throws \think\Exception
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->request->filter(['strip_tags', 'trim']);
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
return $this->fetchPatch('index');
|
||||||
|
}
|
||||||
|
if ($this->request->request('keyField')) {
|
||||||
|
return $this->selectpage();
|
||||||
|
}
|
||||||
|
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||||
|
$ticketTable = $this->model->getTable();
|
||||||
|
$clickSub = TicketModel::buildClickCountSubQuery();
|
||||||
|
// 勿 with(splitLink):eagerlyType=0 为 JOIN 预载入,会重写 field 导致子查询 SQL 语法错误
|
||||||
|
$list = $this->model
|
||||||
|
->field($ticketTable . '.*,' . $clickSub)
|
||||||
|
->where($where)
|
||||||
|
->order($sort, $order)
|
||||||
|
->paginate($limit);
|
||||||
|
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||||
|
return json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步统计字段仅由接口写入,禁止表单提交篡改
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function preExcludeFields($params): array
|
||||||
|
{
|
||||||
|
$params = parent::preExcludeFields($params);
|
||||||
|
unset(
|
||||||
|
$params['complete_count'],
|
||||||
|
$params['inbound_count'],
|
||||||
|
$params['speed_per_hour'],
|
||||||
|
$params['number_count'],
|
||||||
|
$params['number_offline_count'],
|
||||||
|
$params['number_banned_count'],
|
||||||
|
$params['online_count'],
|
||||||
|
$params['sync_status'],
|
||||||
|
$params['sync_time'],
|
||||||
|
$params['sync_message'],
|
||||||
|
$params['click_count']
|
||||||
|
);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->fetchPatch('add');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||||
|
$params[$this->dataLimitField] = $this->auth->id;
|
||||||
|
}
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate);
|
||||||
|
}
|
||||||
|
$result = $this->model->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were inserted'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $ids
|
||||||
|
* @return string
|
||||||
|
* @throws DbException
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$rowData = $row->toArray();
|
||||||
|
$rowData['start_time'] = $rowData['start_time_text'] ?? '';
|
||||||
|
$rowData['end_time'] = $rowData['end_time_text'] ?? '';
|
||||||
|
$this->view->assign('row', $rowData);
|
||||||
|
return $this->fetchPatch('edit');
|
||||||
|
}
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
if (($params['number_type'] ?? '') !== 'custom') {
|
||||||
|
$params['number_type_custom'] = '';
|
||||||
|
}
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||||
|
$row->validateFailException()->validate($validate);
|
||||||
|
}
|
||||||
|
$result = $row->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were updated'));
|
||||||
|
}
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出后台 JS(patches 未部署到 public 时)
|
||||||
|
*/
|
||||||
|
public function script(): void
|
||||||
|
{
|
||||||
|
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||||
|
}
|
||||||
|
if (!is_file($jsFile)) {
|
||||||
|
$this->error('脚本文件不存在');
|
||||||
|
}
|
||||||
|
$content = file_get_contents($jsFile);
|
||||||
|
if ($content === false) {
|
||||||
|
$this->error('读取脚本失败');
|
||||||
|
}
|
||||||
|
$response = response($content, 200, [
|
||||||
|
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control' => 'public, max-age=3600',
|
||||||
|
]);
|
||||||
|
throw new \think\exception\HttpResponseException($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Id' => '号码ID',
|
||||||
|
'Link_url' => '分流链接',
|
||||||
|
'Ticket_name' => '工单名称',
|
||||||
|
'Number' => '号码',
|
||||||
|
'Numbers' => '号码',
|
||||||
|
'Number_type' => '号码类型',
|
||||||
|
'Number_type_custom' => '自定义号码类型',
|
||||||
|
'Split_link_id' => '选择链接',
|
||||||
|
'Visit_count' => '访问次数',
|
||||||
|
'Inbound_count' => '进线人数',
|
||||||
|
'Status' => '状态',
|
||||||
|
'Manual_manage' => '手动管理',
|
||||||
|
'Createtime' => '创建时间',
|
||||||
|
'Section basic' => '基础信息',
|
||||||
|
'Status normal' => '正常',
|
||||||
|
'Status hidden' => '停用',
|
||||||
|
'Number type custom' => '自定义',
|
||||||
|
'Manual manage yes' => '是',
|
||||||
|
'Manual manage no' => '否',
|
||||||
|
'Please select' => '请选择',
|
||||||
|
'Numbers placeholder' => '每行填写一个号码',
|
||||||
|
'Batch update title' => '批量更新号码',
|
||||||
|
'Batch selected count' => '已选号码',
|
||||||
|
'Batch update status' => '更新状态',
|
||||||
|
'Batch update btn' => '更新状态',
|
||||||
|
'Please fill at least one number' => '请至少填写一个有效号码',
|
||||||
|
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
|
||||||
|
'Number already exists for this link' => '该链接下已存在相同号码',
|
||||||
|
'Inserted %d number(s)' => '成功添加 %d 条号码',
|
||||||
|
'Skipped %d duplicate(s)' => '跳过 %d 条重复号码',
|
||||||
|
'Invalid status' => '状态无效',
|
||||||
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
|
'Batch update success' => '批量更新成功',
|
||||||
|
'Unit count' => '个',
|
||||||
|
];
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Ticket_type' => '工单类型',
|
||||||
|
'Ticket_name' => '工单名称',
|
||||||
|
'Ticket_url' => '工单链接',
|
||||||
|
'Ticket_total' => '工单总量',
|
||||||
|
'Split_link_id' => '分流链接',
|
||||||
|
'View split link tip' => '点击查看分流链接与复制完整 URL',
|
||||||
|
'Copy link code tip' => '复制链接码',
|
||||||
|
'Number_type' => '号码类型',
|
||||||
|
'Number_type_custom' => '自定义号码类型',
|
||||||
|
'Start_time' => '开始时间',
|
||||||
|
'End_time' => '到期时间',
|
||||||
|
'Order_limit' => '单号上限',
|
||||||
|
'Assign_ratio' => '下号比率',
|
||||||
|
'Account' => '工单账号',
|
||||||
|
'Password' => '工单密码',
|
||||||
|
'Status' => '状态',
|
||||||
|
'Status normal' => '正常',
|
||||||
|
'Status hidden' => '停用',
|
||||||
|
'Complete_count' => '完成数量',
|
||||||
|
'Ticket_progress' => '工单进度',
|
||||||
|
'Inbound_ratio' => '进线比例',
|
||||||
|
'Speed_per_hour' => '当前速度',
|
||||||
|
'Number_count' => '号码数量',
|
||||||
|
'Number_count_detail' => '离线 %s / 封号 %s',
|
||||||
|
'Sync_status' => '同步状态',
|
||||||
|
'Sync status success' => '同步成功',
|
||||||
|
'Sync status error' => '同步异常',
|
||||||
|
'Sync status pending' => '待同步',
|
||||||
|
'Sync display success' => '同步成功 / 在线人数 %s',
|
||||||
|
'Sync display error' => '同步异常',
|
||||||
|
'Createtime' => '创建时间',
|
||||||
|
'Section basic' => '基础信息',
|
||||||
|
'Section time rule' => '时间与规则',
|
||||||
|
'Section account' => '账号信息',
|
||||||
|
'Number type custom' => '自定义',
|
||||||
|
'Ticket type xinghe' => '星河云控',
|
||||||
|
'Ticket type haiwang' => '海王',
|
||||||
|
'Ticket type taiji' => '太极云控',
|
||||||
|
'Ticket type huojian' => '火箭云控',
|
||||||
|
'Ticket type ss_channel'=> 'SS云控(Channel)',
|
||||||
|
'Ticket type ss_customer'=> 'SS云控(Customer)',
|
||||||
|
'Ticket type yifafa' => '译发发云控',
|
||||||
|
'Ticket type a2c' => 'A2C云控',
|
||||||
|
'Ticket type ceo_scrm' => 'CEO SCRM',
|
||||||
|
'Ticket type whatshub' => 'Whatshub云控',
|
||||||
|
'Ticket type sihai' => '四海云控',
|
||||||
|
'End time must after start' => '到期时间必须晚于开始时间',
|
||||||
|
'Please select' => '请选择',
|
||||||
|
];
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitPlatformDomainService;
|
||||||
|
use think\Db;
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流号码模型
|
||||||
|
*/
|
||||||
|
class Number extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'split_number';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
|
protected $createTime = 'createtime';
|
||||||
|
protected $updateTime = 'updatetime';
|
||||||
|
protected $deleteTime = false;
|
||||||
|
|
||||||
|
protected $append = [
|
||||||
|
'number_type_text',
|
||||||
|
'link_url_text',
|
||||||
|
'status_text',
|
||||||
|
'manual_manage_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码类型(与工单模块一致)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getNumberTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whatsapp' => 'WhatsApp',
|
||||||
|
'telegram' => 'Telegram',
|
||||||
|
'line' => 'Line',
|
||||||
|
'custom' => __('Number type custom'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'normal' => __('Status normal'),
|
||||||
|
'hidden' => __('Status hidden'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getManualManageList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Manual manage no'),
|
||||||
|
'1' => __('Manual manage yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联分流链接
|
||||||
|
*/
|
||||||
|
public function splitLink()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumberTypeCustomAttr($value): string
|
||||||
|
{
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setManualManageAttr($value): int
|
||||||
|
{
|
||||||
|
return (int) $value === 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumberTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$type = (string) ($data['number_type'] ?? '');
|
||||||
|
if ($type === 'custom') {
|
||||||
|
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||||
|
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||||
|
}
|
||||||
|
$list = $this->getNumberTypeList();
|
||||||
|
return $list[$type] ?? $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表展示用:仅显示分流链接码(非完整 URL)
|
||||||
|
*/
|
||||||
|
public function getLinkUrlTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
if (isset($data['split_link']) && is_array($data['split_link'])) {
|
||||||
|
return (string) ($data['split_link']['link_code'] ?? '');
|
||||||
|
}
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return (string) Link::where('id', $linkId)->value('link_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['status'] ?? '');
|
||||||
|
$list = $this->getStatusList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getManualManageTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['manual_manage'] ?? 0));
|
||||||
|
$list = $this->getManualManageList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据链接码生成完整分流 URL
|
||||||
|
*/
|
||||||
|
public static function buildLinkUrl(string $linkCode): string
|
||||||
|
{
|
||||||
|
$linkCode = trim($linkCode);
|
||||||
|
if ($linkCode === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = trim((string) \think\Config::get('site.split_platform_domain'));
|
||||||
|
if ($raw === '') {
|
||||||
|
$raw = trim((string) Db::name('config')->where('name', 'split_platform_domain')->value('value'));
|
||||||
|
}
|
||||||
|
$domains = SplitPlatformDomainService::parseList($raw);
|
||||||
|
$domain = $domains[0] ?? '';
|
||||||
|
if ($domain === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return 'https://' . $domain . '/s/' . $linkCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 textarea 号码列表
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function parseNumbersText(string $text): array
|
||||||
|
{
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
|
||||||
|
$numbers = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$num = trim((string) $line);
|
||||||
|
if ($num === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$numbers[$num] = $num;
|
||||||
|
}
|
||||||
|
return array_values($numbers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流工单模型
|
||||||
|
*/
|
||||||
|
class Ticket extends Model
|
||||||
|
{
|
||||||
|
protected $name = 'split_ticket';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
|
protected $createTime = 'createtime';
|
||||||
|
protected $updateTime = 'updatetime';
|
||||||
|
protected $deleteTime = false;
|
||||||
|
|
||||||
|
protected $append = [
|
||||||
|
'ticket_type_text',
|
||||||
|
'number_type_text',
|
||||||
|
'link_code_text',
|
||||||
|
'start_time_text',
|
||||||
|
'end_time_text',
|
||||||
|
'status_text',
|
||||||
|
'click_count',
|
||||||
|
'ticket_progress_text',
|
||||||
|
'inbound_ratio_text',
|
||||||
|
'sync_display_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单类型
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getTicketTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'xinghe' => __('Ticket type xinghe'),
|
||||||
|
'haiwang' => __('Ticket type haiwang'),
|
||||||
|
'taiji' => __('Ticket type taiji'),
|
||||||
|
'huojian' => __('Ticket type huojian'),
|
||||||
|
'ss_channel' => __('Ticket type ss_channel'),
|
||||||
|
'ss_customer' => __('Ticket type ss_customer'),
|
||||||
|
'yifafa' => __('Ticket type yifafa'),
|
||||||
|
'a2c' => __('Ticket type a2c'),
|
||||||
|
'ceo_scrm' => __('Ticket type ceo_scrm'),
|
||||||
|
'whatshub' => __('Ticket type whatshub'),
|
||||||
|
'sihai' => __('Ticket type sihai'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'normal' => __('Status normal'),
|
||||||
|
'hidden' => __('Status hidden'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getSyncStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => __('Sync status success'),
|
||||||
|
'error' => __('Sync status error'),
|
||||||
|
'pending' => __('Sync status pending'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 号码类型
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getNumberTypeList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whatsapp' => 'Whatsapp',
|
||||||
|
'telegram' => 'Telegram',
|
||||||
|
'line' => 'Line',
|
||||||
|
'custom' => __('Number type custom'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联分流链接
|
||||||
|
*/
|
||||||
|
public function splitLink()
|
||||||
|
{
|
||||||
|
// IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064
|
||||||
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表子查询注入的点击数,无则按关联号码汇总
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getClickCountAttr($value, $data): int
|
||||||
|
{
|
||||||
|
if (isset($data['click_count']) && $data['click_count'] !== '' && $data['click_count'] !== null) {
|
||||||
|
return (int) $data['click_count'];
|
||||||
|
}
|
||||||
|
return self::sumVisitCountForTicket($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单进度:完成数量 / 工单总量
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getTicketProgressTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$total = (int) ($data['ticket_total'] ?? 0);
|
||||||
|
$complete = (int) ($data['complete_count'] ?? 0);
|
||||||
|
if ($total <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$percent = round($complete / $total * 100, 2);
|
||||||
|
return $percent . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进线比例:进线人数 / 点击数(点击数=关联号码 visit_count 之和)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getInboundRatioTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$clicks = $this->getClickCountAttr(null, $data);
|
||||||
|
$inbound = (int) ($data['inbound_count'] ?? 0);
|
||||||
|
if ($clicks <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$percent = round($inbound / $clicks * 100, 2);
|
||||||
|
return $percent . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
|
if ($status === 'success') {
|
||||||
|
return sprintf((string) __('Sync display success'), $online);
|
||||||
|
}
|
||||||
|
return (string) __('Sync display error');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartTimeAttr($value): ?int
|
||||||
|
{
|
||||||
|
return self::parseTimeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndTimeAttr($value): ?int
|
||||||
|
{
|
||||||
|
return self::parseTimeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumberTypeCustomAttr($value): string
|
||||||
|
{
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTicketTotalAttr($value): int
|
||||||
|
{
|
||||||
|
return max(0, (int) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTicketTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['ticket_type'] ?? '');
|
||||||
|
$list = $this->getTicketTypeList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumberTypeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$type = (string) ($data['number_type'] ?? '');
|
||||||
|
if ($type === 'custom') {
|
||||||
|
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||||
|
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||||
|
}
|
||||||
|
$list = $this->getNumberTypeList();
|
||||||
|
return $list[$type] ?? $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLinkCodeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
if ($value !== '' && $value !== null) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$code = Link::where('id', $linkId)->value('link_code');
|
||||||
|
return (string) $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartTimeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
return self::formatTimeText($data['start_time'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndTimeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
return self::formatTimeText($data['end_time'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ($data['status'] ?? '');
|
||||||
|
$list = $this->getStatusList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建列表用 click_count 子查询 SQL 片段
|
||||||
|
*/
|
||||||
|
public static function buildClickCountSubQuery(string $ticketTableAlias = ''): string
|
||||||
|
{
|
||||||
|
$ticketTable = (new self())->getTable();
|
||||||
|
$numberTable = (new Number())->getTable();
|
||||||
|
$t = $ticketTableAlias !== '' ? $ticketTableAlias : $ticketTable;
|
||||||
|
return "(SELECT COALESCE(SUM(n.visit_count), 0) FROM `{$numberTable}` n "
|
||||||
|
. "WHERE n.ticket_name = `{$t}`.ticket_name "
|
||||||
|
. "AND n.split_link_id = `{$t}`.split_link_id "
|
||||||
|
. "AND n.admin_id = `{$t}`.admin_id) AS click_count";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private static function sumVisitCountForTicket(array $data): int
|
||||||
|
{
|
||||||
|
$ticketName = (string) ($data['ticket_name'] ?? '');
|
||||||
|
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||||
|
$adminId = (int) ($data['admin_id'] ?? 0);
|
||||||
|
if ($ticketName === '' || $linkId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$sum = Db::name('split_number')
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('admin_id', $adminId)
|
||||||
|
->sum('visit_count');
|
||||||
|
return (int) $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function parseTimeValue($value): ?int
|
||||||
|
{
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
$ts = strtotime((string) $value);
|
||||||
|
return $ts === false ? null : $ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function formatTimeText($value): string
|
||||||
|
{
|
||||||
|
if ($value === '' || $value === null || !is_numeric($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$ts = (int) $value;
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<form id="add-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
{include file="split/number/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<form id="edit-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||||
|
{include file="split/number/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<style>
|
||||||
|
.split-number-form .panel {
|
||||||
|
border-color: #e8ecf1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .panel-heading {
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .panel-body {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
}
|
||||||
|
.split-number-form .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-number-form .control-label {
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.split-number-form .control-label .text-danger {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.split-number-form .form-control {
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.split-number-form .st-grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.split-number-form .st-grid-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.split-number-form .radio-inline {
|
||||||
|
margin-right: 18px;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer > .control-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer > div[class*="col-"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.split-number-form > .layer-footer .btn {
|
||||||
|
min-width: 100px;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section basic')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
{foreach name="numberTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="splitLinkList" item="link"}
|
||||||
|
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
{if isset($row)}
|
||||||
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
|
{else}
|
||||||
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
<textarea id="c-numbers" data-rule="required" class="form-control" name="row[numbers]" rows="8" placeholder="{:__('Numbers placeholder')}"></textarea>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label">{:__('Status')}<span class="text-danger">*</span></label>
|
||||||
|
<div>
|
||||||
|
{foreach name="statusList" item="vo" key="key"}
|
||||||
|
<label class="radio-inline">
|
||||||
|
<input type="radio" name="row[status]" value="{$key|htmlentities}" data-rule="required" {if isset($row) && $row.status==$key}checked{elseif !isset($row) && $key=='normal'/}checked{/if}> {$vo|htmlentities}
|
||||||
|
</label>
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="panel panel-default panel-intro">
|
||||||
|
<div class="panel-heading">
|
||||||
|
{:build_heading(null,FALSE)}
|
||||||
|
<ul class="nav nav-tabs" data-field="status">
|
||||||
|
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
|
||||||
|
{foreach name="statusList" item="vo" key="key"}
|
||||||
|
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key|htmlentities}" data-toggle="tab">{$vo|htmlentities}</a></li>
|
||||||
|
{/foreach}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="myTabContent" class="tab-content">
|
||||||
|
<div class="tab-pane fade active in" id="one">
|
||||||
|
<div class="widget-body no-padding">
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.number/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
</div>
|
||||||
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
data-operate-edit="{:$auth->check('split.number/edit')}"
|
||||||
|
data-operate-del="{:$auth->check('split.number/del')}"
|
||||||
|
width="100%">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<form id="add-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
{include file="split/ticket/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<form id="edit-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||||
|
{include file="split/ticket/form_body" /}
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<style>
|
||||||
|
.split-ticket-form .panel {
|
||||||
|
border-color: #e8ecf1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .panel-heading {
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .panel-body {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.split-ticket-form .control-label {
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.split-ticket-form .control-label .text-danger {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-control {
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.split-ticket-form .form-control:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
/* 单列整行 */
|
||||||
|
.split-ticket-form .st-grid-1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
/* 双列:PC 端 column-gap 控制列间距(不依赖 Bootstrap col padding) */
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
column-gap: 0;
|
||||||
|
row-gap: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.split-ticket-form .st-grid-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.split-ticket-form .st-grid-2 {
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
.split-ticket-form .st-grid-2 .st-grid-cell + .st-grid-cell .form-group {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 底部按钮区(兼容 layer 弹窗 footer 迁移) */
|
||||||
|
.split-ticket-form > .layer-footer {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer > .control-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer > div[class*="col-"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: none;
|
||||||
|
float: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn {
|
||||||
|
min-width: 100px;
|
||||||
|
margin: 0 12px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary:hover,
|
||||||
|
.split-ticket-form > .layer-footer .btn-primary:focus {
|
||||||
|
background-color: #2563eb;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-default {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.split-ticket-form > .layer-footer .btn-default:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section basic')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_type" class="control-label">{:__('Ticket_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-ticket_type" name="row[ticket_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="ticketTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.ticket_type==$key}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_url" class="control-label">{:__('Ticket_url')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_url" data-rule="required" class="form-control" name="row[ticket_url]" type="text" value="{$row.ticket_url|default=''|htmlentities}" maxlength="1000" placeholder="https://">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-ticket_total" class="control-label">{:__('Ticket_total')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-ticket_total" data-rule="required" class="form-control" name="row[ticket_total]" type="number" min="0" step="1" value="{$row.ticket_total|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{foreach name="splitLinkList" item="link"}
|
||||||
|
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||||
|
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required">
|
||||||
|
{foreach name="numberTypeList" item="vo" key="key"}
|
||||||
|
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger split-required-star">*</span></label>
|
||||||
|
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section time rule')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-start_time" class="control-label">{:__('Start_time')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-start_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[start_time]" type="text" value="{$row.start_time|default=''|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-end_time" class="control-label">{:__('End_time')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-end_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[end_time]" type="text" value="{$row.end_time|default=''|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-order_limit" class="control-label">{:__('Order_limit')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-order_limit" data-rule="required;integer;range(0~)" class="form-control" name="row[order_limit]" type="number" min="0" step="1" value="{$row.order_limit|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-assign_ratio" class="control-label">{:__('Assign_ratio')}<span class="text-danger">*</span></label>
|
||||||
|
<input id="c-assign_ratio" data-rule="required;integer;range(0~)" class="form-control" name="row[assign_ratio]" type="number" min="0" step="1" value="{$row.assign_ratio|default='0'|htmlentities}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">{:__('Section account')}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="st-grid-2">
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-account" class="control-label">{:__('Account')}</label>
|
||||||
|
<input id="c-account" class="form-control" name="row[account]" type="text" value="{$row.account|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="st-grid-cell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-password" class="control-label">{:__('Password')}</label>
|
||||||
|
<input id="c-password" class="form-control" name="row[password]" type="text" value="{$row.password|default=''|htmlentities}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="panel panel-default panel-intro">
|
||||||
|
<div class="panel-heading">
|
||||||
|
{:build_heading(null,FALSE)}
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="myTabContent" class="tab-content">
|
||||||
|
<div class="tab-pane fade active in" id="one">
|
||||||
|
<div class="widget-body no-padding">
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.ticket/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.ticket/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
|
</div>
|
||||||
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
||||||
|
data-operate-del="{:$auth->check('split.ticket/del')}"
|
||||||
|
width="100%">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
use think\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流工单数据同步服务(骨架,后续按 ticket_type 对接各云控 API)
|
||||||
|
*
|
||||||
|
* 期望第三方接口 payload 字段映射:
|
||||||
|
* - complete_count int 完成数量
|
||||||
|
* - inbound_count int 进线人数
|
||||||
|
* - speed_per_hour float 每小时进线人数
|
||||||
|
* - number_count int 号码总数(含离线+封号)
|
||||||
|
* - number_offline_count int 可选 离线数
|
||||||
|
* - number_banned_count int 可选 封号数
|
||||||
|
* - online_count int 在线人数
|
||||||
|
*/
|
||||||
|
class SplitTicketSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 同步单条工单(后续实现:按 ticket_type 选择适配器并请求 API)
|
||||||
|
*/
|
||||||
|
public function syncOne(int $ticketId): bool
|
||||||
|
{
|
||||||
|
$ticket = Ticket::get($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// TODO: 调用具体云控适配器获取 $payload
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将同步结果写入工单表
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||||
|
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
||||||
|
'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)),
|
||||||
|
'number_count' => max(0, (int) ($payload['number_count'] ?? 0)),
|
||||||
|
'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)),
|
||||||
|
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||||
|
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||||
|
'sync_status' => $success ? 'success' : 'error',
|
||||||
|
'sync_time' => time(),
|
||||||
|
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
||||||
|
];
|
||||||
|
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||||
|
throw new Exception('工单同步结果保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||||
|
|
||||||
|
var Controller = {
|
||||||
|
index: function () {
|
||||||
|
Table.api.init({
|
||||||
|
extend: {
|
||||||
|
index_url: 'split.number/index' + location.search,
|
||||||
|
add_url: 'split.number/add',
|
||||||
|
edit_url: 'split.number/edit',
|
||||||
|
del_url: 'split.number/del',
|
||||||
|
multi_url: 'split.number/multi',
|
||||||
|
table: 'split_number',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = $("#table");
|
||||||
|
|
||||||
|
table.bootstrapTable({
|
||||||
|
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||||
|
pk: 'id',
|
||||||
|
sortName: 'id',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fixedColumns: true,
|
||||||
|
fixedRightNumber: 1,
|
||||||
|
columns: [
|
||||||
|
[
|
||||||
|
{checkbox: true},
|
||||||
|
{field: 'id', title: __('Id'), sortable: true},
|
||||||
|
{
|
||||||
|
field: 'link_url_text',
|
||||||
|
title: __('Link_url'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
|
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
||||||
|
{
|
||||||
|
field: 'number_type',
|
||||||
|
title: __('Number_type'),
|
||||||
|
searchList: Config.numberTypeList,
|
||||||
|
formatter: Table.api.formatter.normal
|
||||||
|
},
|
||||||
|
{field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true},
|
||||||
|
{field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: __('Status'),
|
||||||
|
searchList: Config.statusList,
|
||||||
|
formatter: Table.api.formatter.toggle,
|
||||||
|
yes: 'normal',
|
||||||
|
no: 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createtime',
|
||||||
|
title: __('Createtime'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
formatter: Table.api.formatter.datetime,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'operate',
|
||||||
|
title: __('Operate'),
|
||||||
|
table: table,
|
||||||
|
events: Table.api.events.operate,
|
||||||
|
formatter: Table.api.formatter.operate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () {
|
||||||
|
var ids = Table.api.selectedids(table);
|
||||||
|
$('.btn-batch-update-status').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.btn-batch-update-status').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if ($(this).hasClass('disabled')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var ids = Table.api.selectedids(table);
|
||||||
|
if (!ids.length) {
|
||||||
|
Toastr.warning(__('Please select at least one item'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Controller.api.openBatchUpdateModal(ids, table);
|
||||||
|
});
|
||||||
|
|
||||||
|
Table.api.bindevent(table);
|
||||||
|
},
|
||||||
|
add: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
edit: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
bindevent: function () {
|
||||||
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
Controller.api.fixSelectPlaceholder();
|
||||||
|
Controller.api.bindNumberTypeToggle();
|
||||||
|
},
|
||||||
|
fixSelectPlaceholder: function () {
|
||||||
|
var text = __('Please select');
|
||||||
|
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||||
|
text = '请选择';
|
||||||
|
}
|
||||||
|
$('#c-split_link_id').each(function () {
|
||||||
|
var $el = $(this);
|
||||||
|
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||||
|
$el.find('option[value=""]').first().text(text);
|
||||||
|
if ($el.data('selectpicker')) {
|
||||||
|
$el.selectpicker('render');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
bindNumberTypeToggle: function () {
|
||||||
|
var $type = $('#c-number_type');
|
||||||
|
var $wrap = $('.split-number-type-custom');
|
||||||
|
var $custom = $('#c-number_type_custom');
|
||||||
|
if (!$type.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var toggle = function () {
|
||||||
|
var val = $type.val();
|
||||||
|
if (val === 'custom') {
|
||||||
|
$wrap.removeClass('hide');
|
||||||
|
$custom.attr('data-rule', 'required');
|
||||||
|
} else {
|
||||||
|
$wrap.addClass('hide');
|
||||||
|
$custom.removeAttr('data-rule');
|
||||||
|
if (!$wrap.closest('form').attr('id') || $('#edit-form').length === 0) {
|
||||||
|
$custom.val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$type.on('changed.bs.select change', toggle);
|
||||||
|
toggle();
|
||||||
|
},
|
||||||
|
openBatchUpdateModal: function (ids, table) {
|
||||||
|
var statusHtml = '';
|
||||||
|
$.each(Config.statusList || {}, function (key, label) {
|
||||||
|
var checked = key === 'normal' ? ' checked' : '';
|
||||||
|
statusHtml += '<label class="radio-inline"><input type="radio" name="batch_status" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||||
|
});
|
||||||
|
var manualHtml = '';
|
||||||
|
$.each(Config.manualManageList || {}, function (key, label) {
|
||||||
|
var checked = key === '0' ? ' checked' : '';
|
||||||
|
manualHtml += '<label class="radio-inline"><input type="radio" name="batch_manual_manage" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||||
|
});
|
||||||
|
var html = [
|
||||||
|
'<div class="split-batch-update-modal" style="padding:18px 22px;">',
|
||||||
|
' <div class="form-group">',
|
||||||
|
' <label class="control-label">' + __('Batch selected count') + '</label>',
|
||||||
|
' <p class="form-control-static" style="font-size:18px;font-weight:600;margin:0;">' + ids.length + ' ' + __('Unit count') + '</p>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="form-group">',
|
||||||
|
' <label class="control-label">' + __('Status') + '</label>',
|
||||||
|
' <div>' + statusHtml + '</div>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="form-group" style="margin-bottom:0;">',
|
||||||
|
' <label class="control-label">' + __('Manual_manage') + '</label>',
|
||||||
|
' <div>' + manualHtml + '</div>',
|
||||||
|
' </div>',
|
||||||
|
'</div>'
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
Layer.open({
|
||||||
|
type: 1,
|
||||||
|
title: __('Batch update title'),
|
||||||
|
area: ['420px', 'auto'],
|
||||||
|
shadeClose: false,
|
||||||
|
content: html,
|
||||||
|
btn: [__('OK'), __('Cancel')],
|
||||||
|
yes: function (index, layero) {
|
||||||
|
var status = layero.find('input[name="batch_status"]:checked').val();
|
||||||
|
var manualManage = layero.find('input[name="batch_manual_manage"]:checked').val();
|
||||||
|
if (typeof status === 'undefined' || typeof manualManage === 'undefined') {
|
||||||
|
Toastr.error(__('Invalid parameters'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/batchupdate',
|
||||||
|
type: 'post',
|
||||||
|
data: {
|
||||||
|
ids: ids.join(','),
|
||||||
|
status: status,
|
||||||
|
manual_manage: manualManage
|
||||||
|
}
|
||||||
|
}, function () {
|
||||||
|
Layer.close(index);
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
$('.btn-batch-update-status').addClass('btn-disabled disabled');
|
||||||
|
Toastr.success(__('Batch update success'));
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Controller;
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'backend/split/link'], function ($, undefined, Backend, Table, Form, SplitLink) {
|
||||||
|
|
||||||
|
var Controller = {
|
||||||
|
index: function () {
|
||||||
|
Table.api.init({
|
||||||
|
extend: {
|
||||||
|
index_url: 'split.ticket/index' + location.search,
|
||||||
|
add_url: 'split.ticket/add',
|
||||||
|
edit_url: 'split.ticket/edit',
|
||||||
|
del_url: 'split.ticket/del',
|
||||||
|
multi_url: 'split.ticket/multi',
|
||||||
|
table: 'split_ticket',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = $("#table");
|
||||||
|
|
||||||
|
table.bootstrapTable({
|
||||||
|
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||||
|
pk: 'id',
|
||||||
|
sortName: 'id',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fixedColumns: true,
|
||||||
|
fixedRightNumber: 1,
|
||||||
|
columns: [
|
||||||
|
[
|
||||||
|
{checkbox: true},
|
||||||
|
{
|
||||||
|
field: 'ticket_type',
|
||||||
|
title: __('Ticket_type'),
|
||||||
|
searchList: Config.ticketTypeList,
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.ticketTypePlain
|
||||||
|
},
|
||||||
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
|
{
|
||||||
|
field: 'link_code_text',
|
||||||
|
title: __('Split_link_id'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.splitLinkCode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'start_time_text',
|
||||||
|
title: __('Start_time'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'end_time_text',
|
||||||
|
title: __('End_time'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{field: 'order_limit', title: __('Order_limit'), operate: false},
|
||||||
|
{field: 'assign_ratio', title: __('Assign_ratio'), operate: false},
|
||||||
|
{field: 'complete_count', title: __('Complete_count'), operate: false, sortable: true},
|
||||||
|
{
|
||||||
|
field: 'ticket_progress_text',
|
||||||
|
title: __('Ticket_progress'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'inbound_ratio_text',
|
||||||
|
title: __('Inbound_ratio'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'speed_per_hour',
|
||||||
|
title: __('Speed_per_hour'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.speedPerHour
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'number_count',
|
||||||
|
title: __('Number_count'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.numberCount
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sync_display_text',
|
||||||
|
title: __('Sync_status'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.syncDisplay
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: __('Status'),
|
||||||
|
searchList: Config.statusList,
|
||||||
|
formatter: Table.api.formatter.toggle,
|
||||||
|
yes: 'normal',
|
||||||
|
no: 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createtime',
|
||||||
|
title: __('Createtime'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
formatter: Table.api.formatter.datetime,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'operate',
|
||||||
|
title: __('Operate'),
|
||||||
|
table: table,
|
||||||
|
events: Table.api.events.operate,
|
||||||
|
formatter: Table.api.formatter.operate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('click', '.btn-ticket-split-link', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var linkCode = $.trim($(this).data('link-code') || '');
|
||||||
|
if (!linkCode || !SplitLink || !SplitLink.api || !SplitLink.api.openCopyModal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SplitLink.api.openCopyModal(linkCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('click', '.btn-ticket-copy-link-code', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var linkCode = $.trim($(this).data('link-code') || '');
|
||||||
|
if (linkCode && SplitLink && SplitLink.api && SplitLink.api.copyText) {
|
||||||
|
SplitLink.api.copyText(linkCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Table.api.bindevent(table);
|
||||||
|
},
|
||||||
|
add: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
edit: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
formatter: {
|
||||||
|
/**
|
||||||
|
* 工单类型:纯文本展示,无链接/标签样式
|
||||||
|
*/
|
||||||
|
ticketTypePlain: function (value, row) {
|
||||||
|
var text = row.ticket_type_text != null && row.ticket_type_text !== ''
|
||||||
|
? String(row.ticket_type_text)
|
||||||
|
: (value != null ? String(value) : '');
|
||||||
|
if (text === '') {
|
||||||
|
return '<span class="text-muted">-</span>';
|
||||||
|
}
|
||||||
|
return '<span class="split-ticket-type-plain">' + Fast.api.escape(text) + '</span>';
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 分流链接:链接样式 + 操作图标(点击文字打开复制弹窗)
|
||||||
|
*/
|
||||||
|
splitLinkCode: function (value) {
|
||||||
|
value = value == null ? '' : String(value);
|
||||||
|
if ($.trim(value) === '') {
|
||||||
|
return '<span class="text-muted">-</span>';
|
||||||
|
}
|
||||||
|
var safe = Fast.api.escape(value);
|
||||||
|
var viewTip = __('View split link tip');
|
||||||
|
var copyTip = __('Copy link code tip');
|
||||||
|
return '<span class="split-ticket-link-cell">'
|
||||||
|
+ '<a href="javascript:;" class="btn-ticket-split-link split-ticket-link-text" data-link-code="' + safe + '"'
|
||||||
|
+ ' data-toggle="tooltip" title="' + Fast.api.escape(viewTip) + '"'
|
||||||
|
+ ' style="font-weight:600;color:#337ab7;text-decoration:underline;cursor:pointer;">' + safe + '</a>'
|
||||||
|
+ ' <a href="javascript:;" class="btn-ticket-copy-link-code text-primary" data-link-code="' + safe + '"'
|
||||||
|
+ ' data-toggle="tooltip" title="' + Fast.api.escape(copyTip) + '"><i class="fa fa-copy"></i></a>'
|
||||||
|
+ '</span>';
|
||||||
|
},
|
||||||
|
speedPerHour: function (value) {
|
||||||
|
var num = parseFloat(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
return num.toFixed(2);
|
||||||
|
},
|
||||||
|
numberCount: function (value, row) {
|
||||||
|
var total = parseInt(value, 10) || 0;
|
||||||
|
var offline = parseInt(row.number_offline_count, 10) || 0;
|
||||||
|
var banned = parseInt(row.number_banned_count, 10) || 0;
|
||||||
|
var tip = __('Number_count_detail').replace('%s', offline).replace('%s', banned);
|
||||||
|
if (offline > 0 || banned > 0) {
|
||||||
|
return '<span data-toggle="tooltip" title="' + Fast.api.escape(tip) + '">' + total + '</span>';
|
||||||
|
}
|
||||||
|
return String(total);
|
||||||
|
},
|
||||||
|
syncDisplay: function (value, row) {
|
||||||
|
var text = value || '';
|
||||||
|
var color = row.sync_status === 'success' ? 'success' : 'danger';
|
||||||
|
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bindevent: function () {
|
||||||
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
Controller.api.fixSelectPlaceholder();
|
||||||
|
Controller.api.bindNumberTypeToggle();
|
||||||
|
Controller.api.bindEndTimeCheck();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* selectpicker 空选项文案改为中文「请选择」
|
||||||
|
*/
|
||||||
|
fixSelectPlaceholder: function () {
|
||||||
|
var text = __('Please select');
|
||||||
|
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||||
|
text = '请选择';
|
||||||
|
}
|
||||||
|
$('#c-ticket_type, #c-split_link_id').each(function () {
|
||||||
|
var $el = $(this);
|
||||||
|
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||||
|
$el.find('option[value=""]').first().text(text);
|
||||||
|
if ($el.data('selectpicker')) {
|
||||||
|
$el.selectpicker('render');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 号码类型为 custom 时显示自定义输入框
|
||||||
|
*/
|
||||||
|
bindNumberTypeToggle: function () {
|
||||||
|
var $type = $('#c-number_type');
|
||||||
|
var $wrap = $('.split-number-type-custom');
|
||||||
|
var $custom = $('#c-number_type_custom');
|
||||||
|
if (!$type.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var toggle = function () {
|
||||||
|
var val = $type.val();
|
||||||
|
if (val === 'custom') {
|
||||||
|
$wrap.removeClass('hide');
|
||||||
|
$custom.attr('data-rule', 'required');
|
||||||
|
} else {
|
||||||
|
$wrap.addClass('hide');
|
||||||
|
$custom.removeAttr('data-rule');
|
||||||
|
$custom.val('');
|
||||||
|
}
|
||||||
|
if ($custom.data('validator')) {
|
||||||
|
$custom.trigger('validate');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$type.on('changed.bs.select change', toggle);
|
||||||
|
toggle();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 前端预校验:到期时间须晚于开始时间(后端为准)
|
||||||
|
*/
|
||||||
|
bindEndTimeCheck: function () {
|
||||||
|
var $form = $('form[role=form]');
|
||||||
|
var $start = $('#c-start_time');
|
||||||
|
var $end = $('#c-end_time');
|
||||||
|
if (!$start.length || !$end.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var parseTs = function (str) {
|
||||||
|
str = $.trim(str || '');
|
||||||
|
if (!str) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var d = new Date(str.replace(/-/g, '/'));
|
||||||
|
return isNaN(d.getTime()) ? 0 : Math.floor(d.getTime() / 1000);
|
||||||
|
};
|
||||||
|
$form.on('submit', function (e) {
|
||||||
|
var s = parseTs($start.val());
|
||||||
|
var en = parseTs($end.val());
|
||||||
|
if (s > 0 && en > 0 && en <= s) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
Layer.msg(__('End time must after start'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Controller;
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||||
|
|
||||||
|
var Controller = {
|
||||||
|
index: function () {
|
||||||
|
Table.api.init({
|
||||||
|
extend: {
|
||||||
|
index_url: 'split.number/index' + location.search,
|
||||||
|
add_url: 'split.number/add',
|
||||||
|
edit_url: 'split.number/edit',
|
||||||
|
del_url: 'split.number/del',
|
||||||
|
table: 'split_number',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = $("#table");
|
||||||
|
|
||||||
|
table.bootstrapTable({
|
||||||
|
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||||
|
pk: 'id',
|
||||||
|
sortName: 'id',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fixedColumns: true,
|
||||||
|
fixedRightNumber: 1,
|
||||||
|
columns: [
|
||||||
|
[
|
||||||
|
{checkbox: true},
|
||||||
|
{field: 'id', title: __('Id'), sortable: true},
|
||||||
|
{
|
||||||
|
field: 'link_url_text',
|
||||||
|
title: __('Link_url'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
|
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
||||||
|
{
|
||||||
|
field: 'number_type',
|
||||||
|
title: __('Number_type'),
|
||||||
|
searchList: Config.numberTypeList,
|
||||||
|
formatter: Table.api.formatter.normal
|
||||||
|
},
|
||||||
|
{field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true},
|
||||||
|
{field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: __('Status'),
|
||||||
|
searchList: Config.statusList,
|
||||||
|
formatter: Table.api.formatter.status
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createtime',
|
||||||
|
title: __('Createtime'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
formatter: Table.api.formatter.datetime,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'operate',
|
||||||
|
title: __('Operate'),
|
||||||
|
table: table,
|
||||||
|
events: Table.api.events.operate,
|
||||||
|
formatter: Table.api.formatter.operate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () {
|
||||||
|
var ids = Table.api.selectedids(table);
|
||||||
|
$('.btn-batch-update-status').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.btn-batch-update-status').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if ($(this).hasClass('disabled')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var ids = Table.api.selectedids(table);
|
||||||
|
if (!ids.length) {
|
||||||
|
Toastr.warning(__('Please select at least one item'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Controller.api.openBatchUpdateModal(ids, table);
|
||||||
|
});
|
||||||
|
|
||||||
|
Table.api.bindevent(table);
|
||||||
|
},
|
||||||
|
add: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
edit: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
bindevent: function () {
|
||||||
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
Controller.api.fixSelectPlaceholder();
|
||||||
|
Controller.api.bindNumberTypeToggle();
|
||||||
|
},
|
||||||
|
fixSelectPlaceholder: function () {
|
||||||
|
var text = __('Please select');
|
||||||
|
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||||
|
text = '请选择';
|
||||||
|
}
|
||||||
|
$('#c-split_link_id').each(function () {
|
||||||
|
var $el = $(this);
|
||||||
|
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||||
|
$el.find('option[value=""]').first().text(text);
|
||||||
|
if ($el.data('selectpicker')) {
|
||||||
|
$el.selectpicker('render');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
bindNumberTypeToggle: function () {
|
||||||
|
var $type = $('#c-number_type');
|
||||||
|
var $wrap = $('.split-number-type-custom');
|
||||||
|
var $custom = $('#c-number_type_custom');
|
||||||
|
if (!$type.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var toggle = function () {
|
||||||
|
var val = $type.val();
|
||||||
|
if (val === 'custom') {
|
||||||
|
$wrap.removeClass('hide');
|
||||||
|
$custom.attr('data-rule', 'required');
|
||||||
|
} else {
|
||||||
|
$wrap.addClass('hide');
|
||||||
|
$custom.removeAttr('data-rule');
|
||||||
|
if (!$wrap.closest('form').attr('id') || $('#edit-form').length === 0) {
|
||||||
|
$custom.val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$type.on('changed.bs.select change', toggle);
|
||||||
|
toggle();
|
||||||
|
},
|
||||||
|
openBatchUpdateModal: function (ids, table) {
|
||||||
|
var statusHtml = '';
|
||||||
|
$.each(Config.statusList || {}, function (key, label) {
|
||||||
|
var checked = key === 'normal' ? ' checked' : '';
|
||||||
|
statusHtml += '<label class="radio-inline"><input type="radio" name="batch_status" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||||
|
});
|
||||||
|
var manualHtml = '';
|
||||||
|
$.each(Config.manualManageList || {}, function (key, label) {
|
||||||
|
var checked = key === '0' ? ' checked' : '';
|
||||||
|
manualHtml += '<label class="radio-inline"><input type="radio" name="batch_manual_manage" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||||
|
});
|
||||||
|
var html = [
|
||||||
|
'<div class="split-batch-update-modal" style="padding:18px 22px;">',
|
||||||
|
' <div class="form-group">',
|
||||||
|
' <label class="control-label">' + __('Batch selected count') + '</label>',
|
||||||
|
' <p class="form-control-static" style="font-size:18px;font-weight:600;margin:0;">' + ids.length + ' ' + __('Unit count') + '</p>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="form-group">',
|
||||||
|
' <label class="control-label">' + __('Status') + '</label>',
|
||||||
|
' <div>' + statusHtml + '</div>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="form-group" style="margin-bottom:0;">',
|
||||||
|
' <label class="control-label">' + __('Manual_manage') + '</label>',
|
||||||
|
' <div>' + manualHtml + '</div>',
|
||||||
|
' </div>',
|
||||||
|
'</div>'
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
Layer.open({
|
||||||
|
type: 1,
|
||||||
|
title: __('Batch update title'),
|
||||||
|
area: ['420px', 'auto'],
|
||||||
|
shadeClose: false,
|
||||||
|
content: html,
|
||||||
|
btn: [__('OK'), __('Cancel')],
|
||||||
|
yes: function (index, layero) {
|
||||||
|
var status = layero.find('input[name="batch_status"]:checked').val();
|
||||||
|
var manualManage = layero.find('input[name="batch_manual_manage"]:checked').val();
|
||||||
|
if (typeof status === 'undefined' || typeof manualManage === 'undefined') {
|
||||||
|
Toastr.error(__('Invalid parameters'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/batchupdate',
|
||||||
|
type: 'post',
|
||||||
|
data: {
|
||||||
|
ids: ids.join(','),
|
||||||
|
status: status,
|
||||||
|
manual_manage: manualManage
|
||||||
|
}
|
||||||
|
}, function () {
|
||||||
|
Layer.close(index);
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
$('.btn-batch-update-status').addClass('btn-disabled disabled');
|
||||||
|
Toastr.success(__('Batch update success'));
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Controller;
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||||
|
|
||||||
|
var Controller = {
|
||||||
|
index: function () {
|
||||||
|
Table.api.init({
|
||||||
|
extend: {
|
||||||
|
index_url: 'split.ticket/index' + location.search,
|
||||||
|
add_url: 'split.ticket/add',
|
||||||
|
edit_url: 'split.ticket/edit',
|
||||||
|
del_url: 'split.ticket/del',
|
||||||
|
multi_url: 'split.ticket/multi',
|
||||||
|
table: 'split_ticket',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = $("#table");
|
||||||
|
|
||||||
|
table.bootstrapTable({
|
||||||
|
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||||
|
pk: 'id',
|
||||||
|
sortName: 'id',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fixedColumns: true,
|
||||||
|
fixedRightNumber: 1,
|
||||||
|
columns: [
|
||||||
|
[
|
||||||
|
{checkbox: true},
|
||||||
|
{
|
||||||
|
field: 'ticket_type',
|
||||||
|
title: __('Ticket_type'),
|
||||||
|
searchList: Config.ticketTypeList,
|
||||||
|
formatter: Table.api.formatter.normal
|
||||||
|
},
|
||||||
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
|
{
|
||||||
|
field: 'link_code_text',
|
||||||
|
title: __('Split_link_id'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'start_time_text',
|
||||||
|
title: __('Start_time'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'end_time_text',
|
||||||
|
title: __('End_time'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{field: 'order_limit', title: __('Order_limit'), operate: false},
|
||||||
|
{field: 'assign_ratio', title: __('Assign_ratio'), operate: false},
|
||||||
|
{field: 'complete_count', title: __('Complete_count'), operate: false, sortable: true},
|
||||||
|
{
|
||||||
|
field: 'ticket_progress_text',
|
||||||
|
title: __('Ticket_progress'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'inbound_ratio_text',
|
||||||
|
title: __('Inbound_ratio'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Table.api.formatter.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'speed_per_hour',
|
||||||
|
title: __('Speed_per_hour'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.speedPerHour
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'number_count',
|
||||||
|
title: __('Number_count'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.numberCount
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sync_display_text',
|
||||||
|
title: __('Sync_status'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.syncDisplay
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: __('Status'),
|
||||||
|
searchList: Config.statusList,
|
||||||
|
formatter: Table.api.formatter.toggle,
|
||||||
|
yes: 'normal',
|
||||||
|
no: 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createtime',
|
||||||
|
title: __('Createtime'),
|
||||||
|
operate: 'RANGE',
|
||||||
|
addclass: 'datetimerange',
|
||||||
|
autocomplete: false,
|
||||||
|
formatter: Table.api.formatter.datetime,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'operate',
|
||||||
|
title: __('Operate'),
|
||||||
|
table: table,
|
||||||
|
events: Table.api.events.operate,
|
||||||
|
formatter: Table.api.formatter.operate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
Table.api.bindevent(table);
|
||||||
|
},
|
||||||
|
add: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
edit: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
formatter: {
|
||||||
|
speedPerHour: function (value) {
|
||||||
|
var num = parseFloat(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
return num.toFixed(2);
|
||||||
|
},
|
||||||
|
numberCount: function (value, row) {
|
||||||
|
var total = parseInt(value, 10) || 0;
|
||||||
|
var offline = parseInt(row.number_offline_count, 10) || 0;
|
||||||
|
var banned = parseInt(row.number_banned_count, 10) || 0;
|
||||||
|
var tip = __('Number_count_detail').replace('%s', offline).replace('%s', banned);
|
||||||
|
if (offline > 0 || banned > 0) {
|
||||||
|
return '<span data-toggle="tooltip" title="' + Fast.api.escape(tip) + '">' + total + '</span>';
|
||||||
|
}
|
||||||
|
return String(total);
|
||||||
|
},
|
||||||
|
syncDisplay: function (value, row) {
|
||||||
|
var text = value || '';
|
||||||
|
var color = row.sync_status === 'success' ? 'success' : 'danger';
|
||||||
|
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bindevent: function () {
|
||||||
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
Controller.api.fixSelectPlaceholder();
|
||||||
|
Controller.api.bindNumberTypeToggle();
|
||||||
|
Controller.api.bindEndTimeCheck();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* selectpicker 空选项文案改为中文「请选择」
|
||||||
|
*/
|
||||||
|
fixSelectPlaceholder: function () {
|
||||||
|
var text = __('Please select');
|
||||||
|
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||||
|
text = '请选择';
|
||||||
|
}
|
||||||
|
$('#c-ticket_type, #c-split_link_id').each(function () {
|
||||||
|
var $el = $(this);
|
||||||
|
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||||
|
$el.find('option[value=""]').first().text(text);
|
||||||
|
if ($el.data('selectpicker')) {
|
||||||
|
$el.selectpicker('render');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 号码类型为 custom 时显示自定义输入框
|
||||||
|
*/
|
||||||
|
bindNumberTypeToggle: function () {
|
||||||
|
var $type = $('#c-number_type');
|
||||||
|
var $wrap = $('.split-number-type-custom');
|
||||||
|
var $custom = $('#c-number_type_custom');
|
||||||
|
if (!$type.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var toggle = function () {
|
||||||
|
var val = $type.val();
|
||||||
|
if (val === 'custom') {
|
||||||
|
$wrap.removeClass('hide');
|
||||||
|
$custom.attr('data-rule', 'required');
|
||||||
|
} else {
|
||||||
|
$wrap.addClass('hide');
|
||||||
|
$custom.removeAttr('data-rule');
|
||||||
|
$custom.val('');
|
||||||
|
}
|
||||||
|
if ($custom.data('validator')) {
|
||||||
|
$custom.trigger('validate');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$type.on('changed.bs.select change', toggle);
|
||||||
|
toggle();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 前端预校验:到期时间须晚于开始时间(后端为准)
|
||||||
|
*/
|
||||||
|
bindEndTimeCheck: function () {
|
||||||
|
var $form = $('form[role=form]');
|
||||||
|
var $start = $('#c-start_time');
|
||||||
|
var $end = $('#c-end_time');
|
||||||
|
if (!$start.length || !$end.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var parseTs = function (str) {
|
||||||
|
str = $.trim(str || '');
|
||||||
|
if (!str) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var d = new Date(str.replace(/-/g, '/'));
|
||||||
|
return isNaN(d.getTime()) ? 0 : Math.floor(d.getTime() / 1000);
|
||||||
|
};
|
||||||
|
$form.on('submit', function (e) {
|
||||||
|
var s = parseTs($start.val());
|
||||||
|
var en = parseTs($end.val());
|
||||||
|
if (s > 0 && en > 0 && en <= s) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
Layer.msg(__('End time must after start'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Controller;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user