From e4f19c09bc292b5f66e402308d5df82d9e30c015 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 14:15:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=B7=E7=A0=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/command/Install/split_number.sql | 70 ++++ .../admin/command/Install/split_ticket.sql | 89 ++++ .../command/Install/split_ticket_stats.sql | 28 ++ application/admin/controller/split/Number.php | 383 ++++++++++++++++++ application/admin/controller/split/Ticket.php | 303 ++++++++++++++ application/admin/lang/zh-cn/split/number.php | 38 ++ application/admin/lang/zh-cn/split/ticket.php | 50 +++ application/admin/model/split/Number.php | 163 ++++++++ application/admin/model/split/Ticket.php | 295 ++++++++++++++ application/admin/validate/split/Number.php | 109 +++++ application/admin/validate/split/Ticket.php | 154 +++++++ application/admin/view/split/number/add.html | 11 + application/admin/view/split/number/edit.html | 12 + .../admin/view/split/number/form_body.html | 146 +++++++ .../admin/view/split/number/index.html | 31 ++ application/admin/view/split/ticket/add.html | 11 + application/admin/view/split/ticket/edit.html | 12 + .../admin/view/split/ticket/form_body.html | 239 +++++++++++ .../admin/view/split/ticket/index.html | 24 ++ deploy-split-number.sh | 13 + deploy-split-ticket.sh | 13 + .../admin/command/Install/split_number.sql | 77 ++++ .../command/Install/split_number_validate.php | 109 +++++ .../admin/command/Install/split_ticket.sql | 89 ++++ .../Install/split_ticket_add_status.sql | 6 + .../command/Install/split_ticket_stats.sql | 28 ++ .../command/Install/split_ticket_validate.php | 154 +++++++ .../admin/controller/split/Number.php | 383 ++++++++++++++++++ .../admin/controller/split/Ticket.php | 306 ++++++++++++++ .../admin/lang/zh-cn/split/number.php | 38 ++ .../admin/lang/zh-cn/split/ticket.php | 52 +++ .../application/admin/model/split/Number.php | 165 ++++++++ .../application/admin/model/split/Ticket.php | 296 ++++++++++++++ .../admin/view/split/number/add.html | 11 + .../admin/view/split/number/edit.html | 12 + .../admin/view/split/number/form_body.html | 146 +++++++ .../admin/view/split/number/index.html | 31 ++ .../admin/view/split/ticket/add.html | 11 + .../admin/view/split/ticket/edit.html | 12 + .../admin/view/split/ticket/form_body.html | 239 +++++++++++ .../admin/view/split/ticket/index.html | 24 ++ .../common/service/SplitTicketSyncService.php | 60 +++ .../public/assets/js/backend/split/number.js | 206 ++++++++++ .../public/assets/js/backend/split/ticket.js | 284 +++++++++++++ public/assets/js/backend/split/number.js | 203 ++++++++++ public/assets/js/backend/split/ticket.js | 233 +++++++++++ 46 files changed, 5369 insertions(+) create mode 100644 application/admin/command/Install/split_number.sql create mode 100644 application/admin/command/Install/split_ticket.sql create mode 100644 application/admin/command/Install/split_ticket_stats.sql create mode 100644 application/admin/controller/split/Number.php create mode 100644 application/admin/controller/split/Ticket.php create mode 100644 application/admin/lang/zh-cn/split/number.php create mode 100644 application/admin/lang/zh-cn/split/ticket.php create mode 100644 application/admin/model/split/Number.php create mode 100644 application/admin/model/split/Ticket.php create mode 100644 application/admin/validate/split/Number.php create mode 100644 application/admin/validate/split/Ticket.php create mode 100644 application/admin/view/split/number/add.html create mode 100644 application/admin/view/split/number/edit.html create mode 100644 application/admin/view/split/number/form_body.html create mode 100644 application/admin/view/split/number/index.html create mode 100644 application/admin/view/split/ticket/add.html create mode 100644 application/admin/view/split/ticket/edit.html create mode 100644 application/admin/view/split/ticket/form_body.html create mode 100644 application/admin/view/split/ticket/index.html create mode 100755 deploy-split-number.sh create mode 100755 deploy-split-ticket.sh create mode 100644 patches/application/admin/command/Install/split_number.sql create mode 100644 patches/application/admin/command/Install/split_number_validate.php create mode 100644 patches/application/admin/command/Install/split_ticket.sql create mode 100644 patches/application/admin/command/Install/split_ticket_add_status.sql create mode 100644 patches/application/admin/command/Install/split_ticket_stats.sql create mode 100644 patches/application/admin/command/Install/split_ticket_validate.php create mode 100644 patches/application/admin/controller/split/Number.php create mode 100644 patches/application/admin/controller/split/Ticket.php create mode 100644 patches/application/admin/lang/zh-cn/split/number.php create mode 100644 patches/application/admin/lang/zh-cn/split/ticket.php create mode 100644 patches/application/admin/model/split/Number.php create mode 100644 patches/application/admin/model/split/Ticket.php create mode 100644 patches/application/admin/view/split/number/add.html create mode 100644 patches/application/admin/view/split/number/edit.html create mode 100644 patches/application/admin/view/split/number/form_body.html create mode 100644 patches/application/admin/view/split/number/index.html create mode 100644 patches/application/admin/view/split/ticket/add.html create mode 100644 patches/application/admin/view/split/ticket/edit.html create mode 100644 patches/application/admin/view/split/ticket/form_body.html create mode 100644 patches/application/admin/view/split/ticket/index.html create mode 100644 patches/application/common/service/SplitTicketSyncService.php create mode 100644 patches/public/assets/js/backend/split/number.js create mode 100644 patches/public/assets/js/backend/split/ticket.js create mode 100644 public/assets/js/backend/split/number.js create mode 100644 public/assets/js/backend/split/ticket.js diff --git a/application/admin/command/Install/split_number.sql b/application/admin/command/Install/split_number.sql new file mode 100644 index 0000000..9abe9c4 --- /dev/null +++ b/application/admin/command/Install/split_number.sql @@ -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; diff --git a/application/admin/command/Install/split_ticket.sql b/application/admin/command/Install/split_ticket.sql new file mode 100644 index 0000000..1ab349f --- /dev/null +++ b/application/admin/command/Install/split_ticket.sql @@ -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; diff --git a/application/admin/command/Install/split_ticket_stats.sql b/application/admin/command/Install/split_ticket_stats.sql new file mode 100644 index 0000000..c94d4dc --- /dev/null +++ b/application/admin/command/Install/split_ticket_stats.sql @@ -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)); diff --git a/application/admin/controller/split/Number.php b/application/admin/controller/split/Number.php new file mode 100644 index 0000000..a3cd1b8 --- /dev/null +++ b/application/admin/controller/split/Number.php @@ -0,0 +1,383 @@ +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> + */ + 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 $params + * @return array + */ + 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); + } +} diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php new file mode 100644 index 0000000..96435cb --- /dev/null +++ b/application/admin/controller/split/Ticket.php @@ -0,0 +1,303 @@ +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> + */ + 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 $params + * @return array + */ + 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); + } +} diff --git a/application/admin/lang/zh-cn/split/number.php b/application/admin/lang/zh-cn/split/number.php new file mode 100644 index 0000000..1e9a53f --- /dev/null +++ b/application/admin/lang/zh-cn/split/number.php @@ -0,0 +1,38 @@ + '号码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' => '个', +]; diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php new file mode 100644 index 0000000..1962e87 --- /dev/null +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -0,0 +1,50 @@ + '工单类型', + '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' => '请选择', +]; diff --git a/application/admin/model/split/Number.php b/application/admin/model/split/Number.php new file mode 100644 index 0000000..ae21727 --- /dev/null +++ b/application/admin/model/split/Number.php @@ -0,0 +1,163 @@ + + */ + public function getNumberTypeList(): array + { + return [ + 'whatsapp' => 'WhatsApp', + 'telegram' => 'Telegram', + 'line' => 'Line', + 'custom' => __('Number type custom'), + ]; + } + + /** + * @return array + */ + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + /** + * @return array + */ + 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 + */ + 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); + } +} diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php new file mode 100644 index 0000000..bc76ce3 --- /dev/null +++ b/application/admin/model/split/Ticket.php @@ -0,0 +1,295 @@ + + */ + 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 + */ + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + /** + * @return array + */ + public function getSyncStatusList(): array + { + return [ + 'success' => __('Sync status success'), + 'error' => __('Sync status error'), + 'pending' => __('Sync status pending'), + ]; + } + + /** + * 号码类型 + * + * @return array + */ + 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 $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 $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 $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 $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 $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) : ''; + } +} diff --git a/application/admin/validate/split/Number.php b/application/admin/validate/split/Number.php new file mode 100644 index 0000000..56b60e3 --- /dev/null +++ b/application/admin/validate/split/Number.php @@ -0,0 +1,109 @@ + '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 : '状态无效'; + } +} diff --git a/application/admin/validate/split/Ticket.php b/application/admin/validate/split/Ticket.php new file mode 100644 index 0000000..d0ef54b --- /dev/null +++ b/application/admin/validate/split/Ticket.php @@ -0,0 +1,154 @@ + '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; + } +} diff --git a/application/admin/view/split/number/add.html b/application/admin/view/split/number/add.html new file mode 100644 index 0000000..3ddb922 --- /dev/null +++ b/application/admin/view/split/number/add.html @@ -0,0 +1,11 @@ +
+ {:token()} + {include file="split/number/form_body" /} + +
diff --git a/application/admin/view/split/number/edit.html b/application/admin/view/split/number/edit.html new file mode 100644 index 0000000..2e03374 --- /dev/null +++ b/application/admin/view/split/number/edit.html @@ -0,0 +1,12 @@ +
+ {:token()} + + {include file="split/number/form_body" /} + +
diff --git a/application/admin/view/split/number/form_body.html b/application/admin/view/split/number/form_body.html new file mode 100644 index 0000000..c29ee48 --- /dev/null +++ b/application/admin/view/split/number/form_body.html @@ -0,0 +1,146 @@ + + +
+
{:__('Section basic')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ {if isset($row)} + + + {else} + + + {/if} +
+
+
+
+ +
+ {foreach name="statusList" item="vo" key="key"} + + {/foreach} +
+
+
+
+
diff --git a/application/admin/view/split/number/index.html b/application/admin/view/split/number/index.html new file mode 100644 index 0000000..c8f4766 --- /dev/null +++ b/application/admin/view/split/number/index.html @@ -0,0 +1,31 @@ +
+
+ {:build_heading(null,FALSE)} + +
+ +
diff --git a/application/admin/view/split/ticket/add.html b/application/admin/view/split/ticket/add.html new file mode 100644 index 0000000..49b49af --- /dev/null +++ b/application/admin/view/split/ticket/add.html @@ -0,0 +1,11 @@ +
+ {:token()} + {include file="split/ticket/form_body" /} + +
diff --git a/application/admin/view/split/ticket/edit.html b/application/admin/view/split/ticket/edit.html new file mode 100644 index 0000000..13a2924 --- /dev/null +++ b/application/admin/view/split/ticket/edit.html @@ -0,0 +1,12 @@ +
+ {:token()} + + {include file="split/ticket/form_body" /} + +
diff --git a/application/admin/view/split/ticket/form_body.html b/application/admin/view/split/ticket/form_body.html new file mode 100644 index 0000000..b47e02d --- /dev/null +++ b/application/admin/view/split/ticket/form_body.html @@ -0,0 +1,239 @@ + + +
+
{:__('Section basic')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+
{:__('Section time rule')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
{:__('Section account')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
diff --git a/application/admin/view/split/ticket/index.html b/application/admin/view/split/ticket/index.html new file mode 100644 index 0000000..06106e7 --- /dev/null +++ b/application/admin/view/split/ticket/index.html @@ -0,0 +1,24 @@ +
+
+ {:build_heading(null,FALSE)} +
+
+ +
+
diff --git a/deploy-split-number.sh b/deploy-split-number.sh new file mode 100755 index 0000000..f0aad57 --- /dev/null +++ b/deploy-split-number.sh @@ -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(若尚未执行),并在权限管理中勾选「分流管理 → 号码管理」。" diff --git a/deploy-split-ticket.sh b/deploy-split-ticket.sh new file mode 100755 index 0000000..f7e5d38 --- /dev/null +++ b/deploy-split-ticket.sh @@ -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(若尚未执行),并在权限管理中勾选「分流管理 → 工单管理」。" diff --git a/patches/application/admin/command/Install/split_number.sql b/patches/application/admin/command/Install/split_number.sql new file mode 100644 index 0000000..214683b --- /dev/null +++ b/patches/application/admin/command/Install/split_number.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; diff --git a/patches/application/admin/command/Install/split_number_validate.php b/patches/application/admin/command/Install/split_number_validate.php new file mode 100644 index 0000000..56b60e3 --- /dev/null +++ b/patches/application/admin/command/Install/split_number_validate.php @@ -0,0 +1,109 @@ + '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 : '状态无效'; + } +} diff --git a/patches/application/admin/command/Install/split_ticket.sql b/patches/application/admin/command/Install/split_ticket.sql new file mode 100644 index 0000000..1ab349f --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket.sql @@ -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; diff --git a/patches/application/admin/command/Install/split_ticket_add_status.sql b/patches/application/admin/command/Install/split_ticket_add_status.sql new file mode 100644 index 0000000..7b51f6d --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket_add_status.sql @@ -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`); diff --git a/patches/application/admin/command/Install/split_ticket_stats.sql b/patches/application/admin/command/Install/split_ticket_stats.sql new file mode 100644 index 0000000..c94d4dc --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket_stats.sql @@ -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)); diff --git a/patches/application/admin/command/Install/split_ticket_validate.php b/patches/application/admin/command/Install/split_ticket_validate.php new file mode 100644 index 0000000..d0ef54b --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket_validate.php @@ -0,0 +1,154 @@ + '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; + } +} diff --git a/patches/application/admin/controller/split/Number.php b/patches/application/admin/controller/split/Number.php new file mode 100644 index 0000000..a3cd1b8 --- /dev/null +++ b/patches/application/admin/controller/split/Number.php @@ -0,0 +1,383 @@ +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> + */ + 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 $params + * @return array + */ + 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); + } +} diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php new file mode 100644 index 0000000..9200b2e --- /dev/null +++ b/patches/application/admin/controller/split/Ticket.php @@ -0,0 +1,306 @@ +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> + */ + 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 $params + * @return array + */ + 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); + } +} diff --git a/patches/application/admin/lang/zh-cn/split/number.php b/patches/application/admin/lang/zh-cn/split/number.php new file mode 100644 index 0000000..7e90daf --- /dev/null +++ b/patches/application/admin/lang/zh-cn/split/number.php @@ -0,0 +1,38 @@ + '号码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' => '个', +]; diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php new file mode 100644 index 0000000..c3c1acc --- /dev/null +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -0,0 +1,52 @@ + '工单类型', + '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' => '请选择', +]; diff --git a/patches/application/admin/model/split/Number.php b/patches/application/admin/model/split/Number.php new file mode 100644 index 0000000..c0faad5 --- /dev/null +++ b/patches/application/admin/model/split/Number.php @@ -0,0 +1,165 @@ + + */ + public function getNumberTypeList(): array + { + return [ + 'whatsapp' => 'WhatsApp', + 'telegram' => 'Telegram', + 'line' => 'Line', + 'custom' => __('Number type custom'), + ]; + } + + /** + * @return array + */ + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + /** + * @return array + */ + 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 + */ + 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); + } +} diff --git a/patches/application/admin/model/split/Ticket.php b/patches/application/admin/model/split/Ticket.php new file mode 100644 index 0000000..d5ba7a6 --- /dev/null +++ b/patches/application/admin/model/split/Ticket.php @@ -0,0 +1,296 @@ + + */ + 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 + */ + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + /** + * @return array + */ + public function getSyncStatusList(): array + { + return [ + 'success' => __('Sync status success'), + 'error' => __('Sync status error'), + 'pending' => __('Sync status pending'), + ]; + } + + /** + * 号码类型 + * + * @return array + */ + 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 $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 $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 $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 $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 $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) : ''; + } +} diff --git a/patches/application/admin/view/split/number/add.html b/patches/application/admin/view/split/number/add.html new file mode 100644 index 0000000..3ddb922 --- /dev/null +++ b/patches/application/admin/view/split/number/add.html @@ -0,0 +1,11 @@ +
+ {:token()} + {include file="split/number/form_body" /} + +
diff --git a/patches/application/admin/view/split/number/edit.html b/patches/application/admin/view/split/number/edit.html new file mode 100644 index 0000000..2e03374 --- /dev/null +++ b/patches/application/admin/view/split/number/edit.html @@ -0,0 +1,12 @@ +
+ {:token()} + + {include file="split/number/form_body" /} + +
diff --git a/patches/application/admin/view/split/number/form_body.html b/patches/application/admin/view/split/number/form_body.html new file mode 100644 index 0000000..c29ee48 --- /dev/null +++ b/patches/application/admin/view/split/number/form_body.html @@ -0,0 +1,146 @@ + + +
+
{:__('Section basic')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ {if isset($row)} + + + {else} + + + {/if} +
+
+
+
+ +
+ {foreach name="statusList" item="vo" key="key"} + + {/foreach} +
+
+
+
+
diff --git a/patches/application/admin/view/split/number/index.html b/patches/application/admin/view/split/number/index.html new file mode 100644 index 0000000..c8f4766 --- /dev/null +++ b/patches/application/admin/view/split/number/index.html @@ -0,0 +1,31 @@ +
+
+ {:build_heading(null,FALSE)} + +
+ +
diff --git a/patches/application/admin/view/split/ticket/add.html b/patches/application/admin/view/split/ticket/add.html new file mode 100644 index 0000000..49b49af --- /dev/null +++ b/patches/application/admin/view/split/ticket/add.html @@ -0,0 +1,11 @@ +
+ {:token()} + {include file="split/ticket/form_body" /} + +
diff --git a/patches/application/admin/view/split/ticket/edit.html b/patches/application/admin/view/split/ticket/edit.html new file mode 100644 index 0000000..13a2924 --- /dev/null +++ b/patches/application/admin/view/split/ticket/edit.html @@ -0,0 +1,12 @@ +
+ {:token()} + + {include file="split/ticket/form_body" /} + +
diff --git a/patches/application/admin/view/split/ticket/form_body.html b/patches/application/admin/view/split/ticket/form_body.html new file mode 100644 index 0000000..b47e02d --- /dev/null +++ b/patches/application/admin/view/split/ticket/form_body.html @@ -0,0 +1,239 @@ + + +
+
{:__('Section basic')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+
{:__('Section time rule')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
{:__('Section account')}
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
diff --git a/patches/application/admin/view/split/ticket/index.html b/patches/application/admin/view/split/ticket/index.html new file mode 100644 index 0000000..06106e7 --- /dev/null +++ b/patches/application/admin/view/split/ticket/index.html @@ -0,0 +1,24 @@ +
+
+ {:build_heading(null,FALSE)} +
+
+ +
+
diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php new file mode 100644 index 0000000..8036b41 --- /dev/null +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -0,0 +1,60 @@ + $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('工单同步结果保存失败'); + } + } +} diff --git a/patches/public/assets/js/backend/split/number.js b/patches/public/assets/js/backend/split/number.js new file mode 100644 index 0000000..f60605b --- /dev/null +++ b/patches/public/assets/js/backend/split/number.js @@ -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 += ''; + }); + var manualHtml = ''; + $.each(Config.manualManageList || {}, function (key, label) { + var checked = key === '0' ? ' checked' : ''; + manualHtml += ''; + }); + var html = [ + '
', + '
', + ' ', + '

' + ids.length + ' ' + __('Unit count') + '

', + '
', + '
', + ' ', + '
' + statusHtml + '
', + '
', + '
', + ' ', + '
' + manualHtml + '
', + '
', + '
' + ].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; +}); diff --git a/patches/public/assets/js/backend/split/ticket.js b/patches/public/assets/js/backend/split/ticket.js new file mode 100644 index 0000000..140a7b3 --- /dev/null +++ b/patches/public/assets/js/backend/split/ticket.js @@ -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 '-'; + } + return '' + Fast.api.escape(text) + ''; + }, + /** + * 分流链接:链接样式 + 操作图标(点击文字打开复制弹窗) + */ + splitLinkCode: function (value) { + value = value == null ? '' : String(value); + if ($.trim(value) === '') { + return '-'; + } + var safe = Fast.api.escape(value); + var viewTip = __('View split link tip'); + var copyTip = __('Copy link code tip'); + return '' + + '' + safe + '' + + ' ' + + ''; + }, + 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 '' + total + ''; + } + return String(total); + }, + syncDisplay: function (value, row) { + var text = value || ''; + var color = row.sync_status === 'success' ? 'success' : 'danger'; + return '' + Fast.api.escape(text) + ''; + } + }, + 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; +}); diff --git a/public/assets/js/backend/split/number.js b/public/assets/js/backend/split/number.js new file mode 100644 index 0000000..33e1d99 --- /dev/null +++ b/public/assets/js/backend/split/number.js @@ -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 += ''; + }); + var manualHtml = ''; + $.each(Config.manualManageList || {}, function (key, label) { + var checked = key === '0' ? ' checked' : ''; + manualHtml += ''; + }); + var html = [ + '
', + '
', + ' ', + '

' + ids.length + ' ' + __('Unit count') + '

', + '
', + '
', + ' ', + '
' + statusHtml + '
', + '
', + '
', + ' ', + '
' + manualHtml + '
', + '
', + '
' + ].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; +}); diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js new file mode 100644 index 0000000..750daaf --- /dev/null +++ b/public/assets/js/backend/split/ticket.js @@ -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 '' + total + ''; + } + return String(total); + }, + syncDisplay: function (value, row) { + var text = value || ''; + var color = row.sync_status === 'success' ? 'success' : 'danger'; + return '' + Fast.api.escape(text) + ''; + } + }, + 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; +});