From 907d78b3aa6ab1cd8bf3a293a4dde48c145e9770 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 3 Jun 2026 12:10:25 +0800 Subject: [PATCH] =?UTF-8?q?=E9=93=BE=E6=8E=A5=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/command/Install/split_config.sql | 15 + .../admin/command/Install/split_link.sql | 29 + .../command/Install/split_link_auto_reply.sql | 12 + application/admin/controller/split/Link.php | 367 ++++++++++++ application/admin/lang/zh-cn/split/link.php | 42 ++ application/admin/model/split/Link.php | 142 +++++ application/admin/validate/split/Link.php | 67 +++ application/admin/view/split/link/add.html | 69 +++ application/admin/view/split/link/edit.html | 70 +++ application/admin/view/split/link/index.html | 46 ++ application/common/library/CountryIso.php | 149 +++++ application/common/model/Config.php | 17 +- .../common/service/SplitAutoReplyService.php | 70 +++ .../common/service/SplitLinkCodeService.php | 76 +++ .../service/SplitPlatformDomainService.php | 59 ++ application/extra/site.php | 5 + deploy-split-link.sh | 13 + deploy_web.php | 0 fix-split-config.sh | 19 + .../admin/command/Install/split_config.sql | 15 + .../admin/command/Install/split_link.sql | 29 + .../command/Install/split_link_auto_reply.sql | 12 + .../admin/controller/split/Link.php | 367 ++++++++++++ .../admin/lang/zh-cn/split/link.php | 42 ++ .../application/admin/model/split/Link.php | 138 +++++ .../application/admin/validate/split/Link.php | 67 +++ .../admin/view/split/link/add.html | 69 +++ .../admin/view/split/link/edit.html | 70 +++ .../admin/view/split/link/index.html | 46 ++ .../application/common/library/CountryIso.php | 149 +++++ patches/application/common/model/Config.php | 242 ++++++++ .../common/service/SplitAutoReplyService.php | 93 +++ .../common/service/SplitLinkCodeService.php | 76 +++ .../service/SplitPlatformDomainService.php | 59 ++ .../public/assets/js/backend/split/link.js | 550 ++++++++++++++++++ patches/test_write.txt | 0 public/assets/js/backend/split/link.js | 515 ++++++++++++++++ test_root_write | 0 38 files changed, 3805 insertions(+), 1 deletion(-) create mode 100644 application/admin/command/Install/split_config.sql create mode 100644 application/admin/command/Install/split_link.sql create mode 100644 application/admin/command/Install/split_link_auto_reply.sql create mode 100644 application/admin/controller/split/Link.php create mode 100644 application/admin/lang/zh-cn/split/link.php create mode 100644 application/admin/model/split/Link.php create mode 100644 application/admin/validate/split/Link.php create mode 100644 application/admin/view/split/link/add.html create mode 100644 application/admin/view/split/link/edit.html create mode 100644 application/admin/view/split/link/index.html create mode 100644 application/common/library/CountryIso.php create mode 100644 application/common/service/SplitAutoReplyService.php create mode 100644 application/common/service/SplitLinkCodeService.php create mode 100644 application/common/service/SplitPlatformDomainService.php create mode 100755 deploy-split-link.sh create mode 100644 deploy_web.php create mode 100755 fix-split-config.sh create mode 100644 patches/application/admin/command/Install/split_config.sql create mode 100644 patches/application/admin/command/Install/split_link.sql create mode 100644 patches/application/admin/command/Install/split_link_auto_reply.sql create mode 100644 patches/application/admin/controller/split/Link.php create mode 100644 patches/application/admin/lang/zh-cn/split/link.php create mode 100644 patches/application/admin/model/split/Link.php create mode 100644 patches/application/admin/validate/split/Link.php create mode 100644 patches/application/admin/view/split/link/add.html create mode 100644 patches/application/admin/view/split/link/edit.html create mode 100644 patches/application/admin/view/split/link/index.html create mode 100644 patches/application/common/library/CountryIso.php create mode 100644 patches/application/common/model/Config.php create mode 100644 patches/application/common/service/SplitAutoReplyService.php create mode 100644 patches/application/common/service/SplitLinkCodeService.php create mode 100644 patches/application/common/service/SplitPlatformDomainService.php create mode 100644 patches/public/assets/js/backend/split/link.js create mode 100644 patches/test_write.txt create mode 100644 public/assets/js/backend/split/link.js create mode 100644 test_root_write diff --git a/application/admin/command/Install/split_config.sql b/application/admin/command/Install/split_config.sql new file mode 100644 index 0000000..b5c15ca --- /dev/null +++ b/application/admin/command/Install/split_config.sql @@ -0,0 +1,15 @@ +-- 分流链接:平台分配域名(支持多个,一行一个) +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_platform_domain', 'split', '平台分配域名', '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用', 'text', '', '', '', '', ' rows="8" placeholder="example.com another.com"', NULL +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_platform_domain' LIMIT 1); + +UPDATE `fa_config` +SET `group` = 'split', + `type` = 'text', + `title` = '平台分配域名', + `tip` = '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用', + `extend` = ' rows="8" placeholder="example.com another.com"' +WHERE `name` = 'split_platform_domain'; diff --git a/application/admin/command/Install/split_link.sql b/application/admin/command/Install/split_link.sql new file mode 100644 index 0000000..8bd1bf2 --- /dev/null +++ b/application/admin/command/Install/split_link.sql @@ -0,0 +1,29 @@ +-- 分流管理 - 链接管理表 +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `fa_split_link`; +CREATE TABLE `fa_split_link` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID', + `countries` varchar(500) NOT NULL DEFAULT '' COMMENT '投放国家(ISO2逗号分隔)', + `link_code` char(9) NOT NULL DEFAULT '' COMMENT '分流链接(9位小写字母)', + `description` varchar(255) NOT NULL DEFAULT '' COMMENT '链接描述', + `auto_reply` text COMMENT '自动回复语句(一行一条)', + `ip_protect` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT 'IP防护:0=关闭,1=开启', + `random_shuffle` 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 `link_code` (`link_code`), + KEY `admin_id` (`admin_id`), + KEY `status` (`status`) +) 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', 0, 'split', '分流管理', 'fa fa-random', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split' LIMIT 1); diff --git a/application/admin/command/Install/split_link_auto_reply.sql b/application/admin/command/Install/split_link_auto_reply.sql new file mode 100644 index 0000000..1847b0c --- /dev/null +++ b/application/admin/command/Install/split_link_auto_reply.sql @@ -0,0 +1,12 @@ +-- 分流链接:自动回复语句字段(一行一条,关联 link_code) +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_link` +ADD COLUMN `auto_reply` text COMMENT '自动回复语句(一行一条)' AFTER `description`; + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', r.id, 'split.link/autoreply', '自动回复', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` r +WHERE r.name = 'split.link' AND r.ismenu = 0 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/autoreply' LIMIT 1) +LIMIT 1; diff --git a/application/admin/controller/split/Link.php b/application/admin/controller/split/Link.php new file mode 100644 index 0000000..93ce85e --- /dev/null +++ b/application/admin/controller/split/Link.php @@ -0,0 +1,367 @@ +request->langset(); + $lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn'; + $langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/link.php'; + if (is_file($langFile)) { + \think\Lang::load($langFile); + } + + $this->model = new \app\admin\model\split\Link(); + $countryList = CountryIso::getOptions(); + $this->view->assign('countryList', $countryList); + $this->view->assign('ipProtectList', $this->model->getIpProtectList()); + $this->view->assign('randomShuffleList', $this->model->getRandomShuffleList()); + $this->view->assign('statusList', $this->model->getStatusList()); + $this->assignconfig('countryList', $countryList); + $this->assignconfig('ipProtectList', $this->model->getIpProtectList()); + $this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList()); + $this->assignconfig('statusList', $this->model->getStatusList()); + + $this->setupPatchFrontend(); + } + + /** + * 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口 + */ + private function setupPatchFrontend(): void + { + $patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js'; + $publicJs = ROOT_PATH . 'public/assets/js/backend/split/link.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.link/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; + } + + /** + * 渲染模板(优先 patches 视图,回退 application) + */ + private function fetchPatch(string $template): string + { + $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; + $appFile = APP_PATH . 'admin/view/split/link/' . $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(); + $list = $this->model + ->where($where) + ->order($sort, $order) + ->paginate($limit); + $result = ['total' => $list->total(), 'rows' => $list->items()]; + return json($result); + } + + /** + * 添加(打开表单时预生成分流链接,可编辑后保存) + */ + public function add() + { + if (false === $this->request->isPost()) { + try { + $defaultLinkCode = (new SplitLinkCodeService())->generateUnique(); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + $this->view->assign('defaultLinkCode', $defaultLinkCode); + 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 (isset($params['link_code'])) { + $params['link_code'] = SplitLinkCodeService::normalize((string) $params['link_code']); + } + + 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, $params); + } + $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(); + } + + /** + * 复制分流链接弹窗所需数据(平台域名 + 我的域名列表) + * + * @return Json + */ + public function copyinfo(): Json + { + $platformDomains = $this->getPlatformDomains(); + + $domainQuery = (new DomainModel())->order('id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $domainQuery->where($this->dataLimitField, 'in', $adminIds); + } + } + + $myDomains = []; + foreach ($domainQuery->select() as $domainRow) { + $myDomains[] = [ + 'id' => (int) $domainRow['id'], + 'domain' => (string) $domainRow['domain'], + 'ns_status' => (string) $domainRow['ns_status'], + 'ns_status_text' => (string) $domainRow['ns_status_text'], + 'dns_status' => (string) $domainRow['dns_status'], + 'dns_status_text' => (string) $domainRow['dns_status_text'], + ]; + } + + $this->success('', null, [ + 'platform_domains' => $platformDomains, + 'my_domains' => $myDomains, + 'domain_index_url' => (string) url('domain/index'), + 'domain_add_url' => (string) url('domain/add'), + 'config_index_url' => (string) url('general/config/index'), + ]); + } + + /** + * 读取平台分配域名列表(优先 site 配置,回退数据库) + * + * @return array + */ + private function getPlatformDomains(): array + { + $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')); + } + return SplitPlatformDomainService::parseList($raw); + } + + /** + * 自动回复:读取 / 保存与当前链接关联的回复语句 + * + * @param string|null $ids 链接 ID + * @return Json + * @throws DbException + */ + public function autoreply($ids = null): Json + { + $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 ($this->request->isPost()) { + $autoReply = (string) $this->request->post('auto_reply', ''); + $lines = SplitAutoReplyService::parseLines($autoReply); + try { + $row->save([ + 'auto_reply' => SplitAutoReplyService::formatStorage($lines), + ]); + } catch (PDOException|Exception $e) { + $this->error($e->getMessage()); + } + $this->success(__('Auto reply saved')); + } + + $this->success('', null, [ + 'id' => (int) $row['id'], + 'link_code' => (string) $row['link_code'], + 'auto_reply' => SplitAutoReplyService::formatDisplay((string) $row->getAttr('auto_reply')), + 'line_count' => count($row->getAutoReplyLines()), + ]); + } + + /** + * 编辑(分流链接码不可修改) + * + * @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($row[$this->dataLimitField], $adminIds)) { + $this->error(__('You have no permission')); + } + + if (false === $this->request->isPost()) { + $this->view->assign('row', $row); + $this->view->assign('selectedCountries', $row->getSelectedCountries()); + return $this->fetchPatch('edit'); + } + + $params = $this->request->post('row/a', []); + if ($params === []) { + $this->error(__('Parameter %s can not be empty', '')); + } + + $params = $this->preExcludeFields($params); + unset($params['link_code']); + + $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(); + } + + /** + * 输出后台列表/表单 JS(patches 未部署到 public 时由 RequireJS 加载) + */ + public function script(): void + { + $jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js'; + if (!is_file($jsFile)) { + $jsFile = ROOT_PATH . 'public/assets/js/backend/split/link.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/link.php b/application/admin/lang/zh-cn/split/link.php new file mode 100644 index 0000000..08e2eed --- /dev/null +++ b/application/admin/lang/zh-cn/split/link.php @@ -0,0 +1,42 @@ + '投放国家', + 'Countries help' => '可多选,存储为 ISO 两位国家代码', + 'Link_code' => '分流链接', + 'Link code auto tip' => '保存后系统自动生成 9 位小写字母链接码', + 'Description' => '链接描述', + 'Ip_protect' => 'IP防护', + 'Random_shuffle' => '随机打乱', + 'Status' => '状态', + 'Createtime' => '创建时间', + 'Ip protect off' => '关闭', + 'Ip protect on' => '开启', + 'Random shuffle off' => '关闭', + 'Random shuffle on' => '开启', + 'Status normal' => '正常', + 'Status hidden' => '停用', + 'Copy split link' => '复制分流链接', + 'Manage my domains' => '管理我的域名', + 'Select main domain' => '选择主域名', + 'Platform assigned domain' => '平台分配域名', + 'My domains' => '我的域名', + 'Split platform domain empty' => '尚未配置平台分配域名,请前往', + 'Go system config' => '系统设置', + 'Split my domain empty' => '暂无域名,请先在域名管理中添加', + 'Split domain prefix tip' => '想使用自己的域名作为分流前缀,可以先去「我的域名」完成接入。', + 'Go add domain' => '去添加', + 'Generated result' => '生成结果', + 'Copy' => '复制', + 'Copy success' => '复制成功', + 'Copy failed' => '复制失败', + 'Open test' => '打开测试', + 'Close' => '关闭', + 'Split url empty' => '请先选择有效主域名', + 'System config' => '系统配置', + 'Auto reply' => '自动回复', + 'Reply statements' => '回复语句', + 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联', + 'Auto reply saved' => '自动回复已保存', + 'Reply statements column' => '回复语', +]; diff --git a/application/admin/model/split/Link.php b/application/admin/model/split/Link.php new file mode 100644 index 0000000..3bb6342 --- /dev/null +++ b/application/admin/model/split/Link.php @@ -0,0 +1,142 @@ + __('Ip protect off'), + '1' => __('Ip protect on'), + ]; + } + + public function getRandomShuffleList(): array + { + return [ + '0' => __('Random shuffle off'), + '1' => __('Random shuffle on'), + ]; + } + + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + public function setCountriesAttr($value): string + { + if (is_array($value)) { + return CountryIso::codesToStorage($value); + } + return CountryIso::codesToStorage(explode(',', (string)$value)); + } + + /** + * 分流链接码统一为小写 + */ + public function setLinkCodeAttr($value): string + { + return strtolower(trim((string) $value)); + } + + /** + * 自动回复:存储为一行一条(换行分隔) + * + * @param mixed $value + */ + public function setAutoReplyAttr($value): string + { + return \app\common\service\SplitAutoReplyService::formatStorage($value); + } + + /** + * 自动回复语句数组 + * + * @return array + */ + public function getAutoReplyLines(): array + { + return \app\common\service\SplitAutoReplyService::parseLines((string) $this->getAttr('auto_reply')); + } + + public function getCountriesTextAttr($value, $data): string + { + return CountryIso::codesToText((string)($data['countries'] ?? '')); + } + + /** + * 列表「回复语」列展示(多行合并为单行,供省略号截断) + */ + public function getAutoReplyTextAttr($value, $data): string + { + $lines = \app\common\service\SplitAutoReplyService::parseLines((string)($data['auto_reply'] ?? '')); + if ($lines === []) { + return ''; + } + return implode(';', $lines); + } + + public function getIpProtectTextAttr($value, $data): string + { + $value = $value !== '' && $value !== null ? $value : ($data['ip_protect'] ?? ''); + $list = $this->getIpProtectList(); + return $list[(string)$value] ?? ''; + } + + public function getRandomShuffleTextAttr($value, $data): string + { + $value = $value !== '' && $value !== null ? $value : ($data['random_shuffle'] ?? ''); + $list = $this->getRandomShuffleList(); + return $list[(string)$value] ?? ''; + } + + public function getStatusTextAttr($value, $data): string + { + $value = $value ?: ($data['status'] ?? ''); + $list = $this->getStatusList(); + return $list[$value] ?? ''; + } + + /** + * 已选国家 ISO 代码数组(供编辑表单多选回显) + * + * @return array + */ + public function getSelectedCountries(): array + { + $countries = (string)$this->getAttr('countries'); + if ($countries === '') { + return []; + } + return CountryIso::normalizeCodes(explode(',', $countries)); + } +} diff --git a/application/admin/validate/split/Link.php b/application/admin/validate/split/Link.php new file mode 100644 index 0000000..b08fa0f --- /dev/null +++ b/application/admin/validate/split/Link.php @@ -0,0 +1,67 @@ + 'require|checkCountries', + 'link_code' => 'require|checkLinkCode', + 'description' => 'require|max:255', + 'ip_protect' => 'in:0,1', + 'random_shuffle' => 'in:0,1', + 'status' => 'in:normal,hidden', + ]; + + protected $message = [ + 'countries.require' => '请至少选择一个投放国家', + 'link_code.require' => '请填写分流链接', + 'description.require' => '请填写链接描述', + 'description.max' => '链接描述不能超过255个字符', + ]; + + protected $scene = [ + 'add' => ['countries', 'link_code', 'description', 'ip_protect', 'random_shuffle', 'status'], + 'edit' => ['countries', 'description', 'ip_protect', 'random_shuffle', 'status'], + ]; + + /** + * @param mixed $value + * @return bool|string + */ + protected function checkCountries($value) + { + $codes = is_array($value) ? $value : explode(',', (string)$value); + if (CountryIso::normalizeCodes($codes) === []) { + return '请至少选择一个有效的投放国家'; + } + return true; + } + + /** + * 校验分流链接格式与唯一性(仅新增) + * + * @param mixed $value + * @return bool|string + */ + protected function checkLinkCode($value) + { + $code = SplitLinkCodeService::normalize((string) $value); + if (!SplitLinkCodeService::isValidFormat($code)) { + return '分流链接须为9位小写字母(a-z)'; + } + if (!SplitLinkCodeService::isAvailable($code)) { + return '该分流链接已被使用,请更换'; + } + return true; + } +} diff --git a/application/admin/view/split/link/add.html b/application/admin/view/split/link/add.html new file mode 100644 index 0000000..de500ee --- /dev/null +++ b/application/admin/view/split/link/add.html @@ -0,0 +1,69 @@ +
+ {:token()} + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ {foreach name="statusList" item="vo"} + + {/foreach} +
+
+
+ + +
diff --git a/application/admin/view/split/link/edit.html b/application/admin/view/split/link/edit.html new file mode 100644 index 0000000..e0d5b41 --- /dev/null +++ b/application/admin/view/split/link/edit.html @@ -0,0 +1,70 @@ +
+ {:token()} + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ {foreach name="statusList" item="vo"} + + {/foreach} +
+
+
+ + +
diff --git a/application/admin/view/split/link/index.html b/application/admin/view/split/link/index.html new file mode 100644 index 0000000..122c8a2 --- /dev/null +++ b/application/admin/view/split/link/index.html @@ -0,0 +1,46 @@ +
+ +
+ {:build_heading(null,FALSE)} + +
+ + +
+
+
+
+
+ + {:__('Add')} + {:__('Edit')} + {:__('Delete')} + + + + + +
+ +
+
+
+ +
+
+
diff --git a/application/common/library/CountryIso.php b/application/common/library/CountryIso.php new file mode 100644 index 0000000..e7261a4 --- /dev/null +++ b/application/common/library/CountryIso.php @@ -0,0 +1,149 @@ + 中文名 + * + * @var array + */ + private const COUNTRIES = [ + 'CN' => '中国', + 'HK' => '中国香港', + 'MO' => '中国澳门', + 'TW' => '中国台湾', + 'US' => '美国', + 'CA' => '加拿大', + 'GB' => '英国', + 'DE' => '德国', + 'FR' => '法国', + 'IT' => '意大利', + 'ES' => '西班牙', + 'NL' => '荷兰', + 'BE' => '比利时', + 'CH' => '瑞士', + 'AT' => '奥地利', + 'SE' => '瑞典', + 'NO' => '挪威', + 'DK' => '丹麦', + 'FI' => '芬兰', + 'IE' => '爱尔兰', + 'PT' => '葡萄牙', + 'PL' => '波兰', + 'CZ' => '捷克', + 'HU' => '匈牙利', + 'RO' => '罗马尼亚', + 'GR' => '希腊', + 'RU' => '俄罗斯', + 'UA' => '乌克兰', + 'TR' => '土耳其', + 'IL' => '以色列', + 'SA' => '沙特阿拉伯', + 'AE' => '阿联酋', + 'QA' => '卡塔尔', + 'KW' => '科威特', + 'IN' => '印度', + 'PK' => '巴基斯坦', + 'BD' => '孟加拉国', + 'TH' => '泰国', + 'VN' => '越南', + 'MY' => '马来西亚', + 'SG' => '新加坡', + 'ID' => '印度尼西亚', + 'PH' => '菲律宾', + 'JP' => '日本', + 'KR' => '韩国', + 'AU' => '澳大利亚', + 'NZ' => '新西兰', + 'BR' => '巴西', + 'MX' => '墨西哥', + 'AR' => '阿根廷', + 'CL' => '智利', + 'CO' => '哥伦比亚', + 'PE' => '秘鲁', + 'ZA' => '南非', + 'EG' => '埃及', + 'NG' => '尼日利亚', + 'KE' => '肯尼亚', + ]; + + /** + * 下拉选项 ISO2 => 中文名 + * + * @return array + */ + public static function getOptions(): array + { + return self::COUNTRIES; + } + + /** + * 校验是否为合法 ISO2 代码 + */ + public static function isValidCode(string $code): bool + { + $code = strtoupper(trim($code)); + return isset(self::COUNTRIES[$code]); + } + + /** + * 规范化并过滤国家代码列表 + * + * @param array|string $codes + * @return array + */ + public static function normalizeCodes($codes): array + { + if (is_string($codes)) { + $codes = explode(',', $codes); + } + if (!is_array($codes)) { + return []; + } + $result = []; + foreach ($codes as $code) { + $code = strtoupper(trim((string)$code)); + if ($code !== '' && self::isValidCode($code) && !in_array($code, $result, true)) { + $result[] = $code; + } + } + sort($result); + return $result; + } + + /** + * 逗号分隔 ISO 代码转中文展示 + */ + public static function codesToText(string $codes): string + { + if ($codes === '') { + return ''; + } + $parts = []; + foreach (explode(',', $codes) as $code) { + $code = strtoupper(trim($code)); + if ($code === '') { + continue; + } + $parts[] = self::COUNTRIES[$code] ?? $code; + } + return implode(',', $parts); + } + + /** + * 数组转存储字符串 + * + * @param array $codes + */ + public static function codesToStorage(array $codes): string + { + return implode(',', self::normalizeCodes($codes)); + } +} diff --git a/application/common/model/Config.php b/application/common/model/Config.php index d090492..47ce0fb 100755 --- a/application/common/model/Config.php +++ b/application/common/model/Config.php @@ -1,5 +1,7 @@ where('name', 'configgroup')->value('value'); + $dbList = json_decode((string) $dbJson, true); + if (is_array($dbList) && $dbList !== []) { + $groupList = $dbList; + } + } catch (\Throwable $e) { + // 数据库不可用时回退 site.php + } foreach ($groupList as $k => &$v) { $v = __($v); } diff --git a/application/common/service/SplitAutoReplyService.php b/application/common/service/SplitAutoReplyService.php new file mode 100644 index 0000000..26c0287 --- /dev/null +++ b/application/common/service/SplitAutoReplyService.php @@ -0,0 +1,70 @@ + + */ + public static function parseLines(string $raw): array + { + $raw = trim($raw); + if ($raw === '') { + return []; + } + $parts = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $lines = []; + foreach ($parts as $part) { + $line = trim((string) $part); + if ($line === '') { + continue; + } + if (strlen($line) > self::MAX_LINE_LENGTH) { + $line = mb_substr($line, 0, self::MAX_LINE_LENGTH, 'UTF-8'); + } + $lines[] = $line; + if (count($lines) >= self::MAX_LINES) { + break; + } + } + return $lines; + } + + /** + * 格式化为数据库存储(换行分隔) + * + * @param string|array $value + */ + public static function formatStorage($value): string + { + if (is_array($value)) { + $lines = $value; + } else { + $lines = self::parseLines((string) $value); + } + return implode("\n", $lines); + } + + /** + * 供表单 textarea 回显 + */ + public static function formatDisplay(string $stored): string + { + $lines = self::parseLines($stored); + return implode("\n", $lines); + } +} diff --git a/application/common/service/SplitLinkCodeService.php b/application/common/service/SplitLinkCodeService.php new file mode 100644 index 0000000..d1ea0a6 --- /dev/null +++ b/application/common/service/SplitLinkCodeService.php @@ -0,0 +1,76 @@ + 0) { + $query->where('id', '<>', $excludeId); + } + return !$query->find(); + } + + /** + * 生成唯一 9 位小写字母链接码 + * + * @throws Exception + */ + public function generateUnique(): string + { + for ($i = 0; $i < self::MAX_ATTEMPTS; $i++) { + $code = $this->generateRandom(); + if (!Link::where('link_code', $code)->find()) { + return $code; + } + } + throw new Exception('分流链接生成失败,请稍后重试'); + } + + private function generateRandom(): string + { + $chars = self::CHARSET; + $max = strlen($chars) - 1; + $code = ''; + for ($i = 0; $i < self::CODE_LENGTH; $i++) { + $code .= $chars[random_int(0, $max)]; + } + return $code; + } +} diff --git a/application/common/service/SplitPlatformDomainService.php b/application/common/service/SplitPlatformDomainService.php new file mode 100644 index 0000000..1845dac --- /dev/null +++ b/application/common/service/SplitPlatformDomainService.php @@ -0,0 +1,59 @@ + + */ + public static function parseList(string $raw): array + { + $raw = trim($raw); + if ($raw === '') { + return []; + } + + $parts = preg_split('/[\r\n,]+/', $raw) ?: []; + $domains = []; + foreach ($parts as $part) { + $domain = DomainModel::normalizeDomain(trim((string) $part)); + if ($domain === '' || isset($domains[$domain])) { + continue; + } + if (!DomainModel::isValidRootDomain($domain)) { + continue; + } + $domains[$domain] = $domain; + } + + return array_values($domains); + } + + /** + * 将域名列表格式化为配置存储文本(一行一个) + * + * @param array $domains + */ + public static function formatList(array $domains): string + { + $lines = []; + foreach ($domains as $domain) { + $domain = DomainModel::normalizeDomain((string) $domain); + if ($domain !== '' && DomainModel::isValidRootDomain($domain)) { + $lines[$domain] = $domain; + } + } + return implode("\n", array_values($lines)); + } +} diff --git a/application/extra/site.php b/application/extra/site.php index 4ed3f15..6612b0e 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -27,6 +27,7 @@ return array ( 'dictionary' => 'Dictionary', 'user' => 'User', 'example' => 'Example', + 'split' => '分流设置', ), 'mail_type' => '1', 'mail_smtp_host' => 'smtp.qq.com', @@ -41,4 +42,8 @@ return array ( 'category2' => 'Category2', 'custom' => 'Custom', ), + 'split_platform_domain' => 'link1.com +link2.com +link3.com +link4.com', ); diff --git a/deploy-split-link.sh b/deploy-split-link.sh new file mode 100755 index 0000000..171f760 --- /dev/null +++ b/deploy-split-link.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_link.php" + exit 1 +fi + +php "$BASE/runtime/deploy_split_link.php" +echo "菜单已写入数据库,请在权限管理中勾选「分流管理」相关节点。" diff --git a/deploy_web.php b/deploy_web.php new file mode 100644 index 0000000..e69de29 diff --git a/fix-split-config.sh b/fix-split-config.sh new file mode 100755 index 0000000..2f0b65c --- /dev/null +++ b/fix-split-config.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 修复系统配置中「分流设置 / 平台分配域名」不显示的问题(需 root) +set -e +BASE="$(cd "$(dirname "$0")" && pwd)" + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 执行: sudo bash $0" + exit 1 +fi + +php "$BASE/runtime/install_split_config_standalone.php" + +cp "$BASE/patches/application/common/model/Config.php" "$BASE/application/common/model/Config.php" +chown www:www "$BASE/application/common/model/Config.php" +php -l "$BASE/application/common/model/Config.php" + +chown www:www "$BASE/application/extra/site.php" 2>/dev/null || true + +echo "完成。请刷新后台「常规管理 -> 系统配置」,应能看到「分流设置」标签及「平台分配域名」字段。" diff --git a/patches/application/admin/command/Install/split_config.sql b/patches/application/admin/command/Install/split_config.sql new file mode 100644 index 0000000..b5c15ca --- /dev/null +++ b/patches/application/admin/command/Install/split_config.sql @@ -0,0 +1,15 @@ +-- 分流链接:平台分配域名(支持多个,一行一个) +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_platform_domain', 'split', '平台分配域名', '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用', 'text', '', '', '', '', ' rows="8" placeholder="example.com another.com"', NULL +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_platform_domain' LIMIT 1); + +UPDATE `fa_config` +SET `group` = 'split', + `type` = 'text', + `title` = '平台分配域名', + `tip` = '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用', + `extend` = ' rows="8" placeholder="example.com another.com"' +WHERE `name` = 'split_platform_domain'; diff --git a/patches/application/admin/command/Install/split_link.sql b/patches/application/admin/command/Install/split_link.sql new file mode 100644 index 0000000..8bd1bf2 --- /dev/null +++ b/patches/application/admin/command/Install/split_link.sql @@ -0,0 +1,29 @@ +-- 分流管理 - 链接管理表 +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `fa_split_link`; +CREATE TABLE `fa_split_link` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID', + `countries` varchar(500) NOT NULL DEFAULT '' COMMENT '投放国家(ISO2逗号分隔)', + `link_code` char(9) NOT NULL DEFAULT '' COMMENT '分流链接(9位小写字母)', + `description` varchar(255) NOT NULL DEFAULT '' COMMENT '链接描述', + `auto_reply` text COMMENT '自动回复语句(一行一条)', + `ip_protect` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT 'IP防护:0=关闭,1=开启', + `random_shuffle` 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 `link_code` (`link_code`), + KEY `admin_id` (`admin_id`), + KEY `status` (`status`) +) 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', 0, 'split', '分流管理', 'fa fa-random', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split' LIMIT 1); diff --git a/patches/application/admin/command/Install/split_link_auto_reply.sql b/patches/application/admin/command/Install/split_link_auto_reply.sql new file mode 100644 index 0000000..1847b0c --- /dev/null +++ b/patches/application/admin/command/Install/split_link_auto_reply.sql @@ -0,0 +1,12 @@ +-- 分流链接:自动回复语句字段(一行一条,关联 link_code) +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_link` +ADD COLUMN `auto_reply` text COMMENT '自动回复语句(一行一条)' AFTER `description`; + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', r.id, 'split.link/autoreply', '自动回复', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` r +WHERE r.name = 'split.link' AND r.ismenu = 0 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/autoreply' LIMIT 1) +LIMIT 1; diff --git a/patches/application/admin/controller/split/Link.php b/patches/application/admin/controller/split/Link.php new file mode 100644 index 0000000..93ce85e --- /dev/null +++ b/patches/application/admin/controller/split/Link.php @@ -0,0 +1,367 @@ +request->langset(); + $lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn'; + $langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/link.php'; + if (is_file($langFile)) { + \think\Lang::load($langFile); + } + + $this->model = new \app\admin\model\split\Link(); + $countryList = CountryIso::getOptions(); + $this->view->assign('countryList', $countryList); + $this->view->assign('ipProtectList', $this->model->getIpProtectList()); + $this->view->assign('randomShuffleList', $this->model->getRandomShuffleList()); + $this->view->assign('statusList', $this->model->getStatusList()); + $this->assignconfig('countryList', $countryList); + $this->assignconfig('ipProtectList', $this->model->getIpProtectList()); + $this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList()); + $this->assignconfig('statusList', $this->model->getStatusList()); + + $this->setupPatchFrontend(); + } + + /** + * 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口 + */ + private function setupPatchFrontend(): void + { + $patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js'; + $publicJs = ROOT_PATH . 'public/assets/js/backend/split/link.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.link/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; + } + + /** + * 渲染模板(优先 patches 视图,回退 application) + */ + private function fetchPatch(string $template): string + { + $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; + $appFile = APP_PATH . 'admin/view/split/link/' . $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(); + $list = $this->model + ->where($where) + ->order($sort, $order) + ->paginate($limit); + $result = ['total' => $list->total(), 'rows' => $list->items()]; + return json($result); + } + + /** + * 添加(打开表单时预生成分流链接,可编辑后保存) + */ + public function add() + { + if (false === $this->request->isPost()) { + try { + $defaultLinkCode = (new SplitLinkCodeService())->generateUnique(); + } catch (Exception $e) { + $this->error($e->getMessage()); + } + $this->view->assign('defaultLinkCode', $defaultLinkCode); + 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 (isset($params['link_code'])) { + $params['link_code'] = SplitLinkCodeService::normalize((string) $params['link_code']); + } + + 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, $params); + } + $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(); + } + + /** + * 复制分流链接弹窗所需数据(平台域名 + 我的域名列表) + * + * @return Json + */ + public function copyinfo(): Json + { + $platformDomains = $this->getPlatformDomains(); + + $domainQuery = (new DomainModel())->order('id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $domainQuery->where($this->dataLimitField, 'in', $adminIds); + } + } + + $myDomains = []; + foreach ($domainQuery->select() as $domainRow) { + $myDomains[] = [ + 'id' => (int) $domainRow['id'], + 'domain' => (string) $domainRow['domain'], + 'ns_status' => (string) $domainRow['ns_status'], + 'ns_status_text' => (string) $domainRow['ns_status_text'], + 'dns_status' => (string) $domainRow['dns_status'], + 'dns_status_text' => (string) $domainRow['dns_status_text'], + ]; + } + + $this->success('', null, [ + 'platform_domains' => $platformDomains, + 'my_domains' => $myDomains, + 'domain_index_url' => (string) url('domain/index'), + 'domain_add_url' => (string) url('domain/add'), + 'config_index_url' => (string) url('general/config/index'), + ]); + } + + /** + * 读取平台分配域名列表(优先 site 配置,回退数据库) + * + * @return array + */ + private function getPlatformDomains(): array + { + $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')); + } + return SplitPlatformDomainService::parseList($raw); + } + + /** + * 自动回复:读取 / 保存与当前链接关联的回复语句 + * + * @param string|null $ids 链接 ID + * @return Json + * @throws DbException + */ + public function autoreply($ids = null): Json + { + $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 ($this->request->isPost()) { + $autoReply = (string) $this->request->post('auto_reply', ''); + $lines = SplitAutoReplyService::parseLines($autoReply); + try { + $row->save([ + 'auto_reply' => SplitAutoReplyService::formatStorage($lines), + ]); + } catch (PDOException|Exception $e) { + $this->error($e->getMessage()); + } + $this->success(__('Auto reply saved')); + } + + $this->success('', null, [ + 'id' => (int) $row['id'], + 'link_code' => (string) $row['link_code'], + 'auto_reply' => SplitAutoReplyService::formatDisplay((string) $row->getAttr('auto_reply')), + 'line_count' => count($row->getAutoReplyLines()), + ]); + } + + /** + * 编辑(分流链接码不可修改) + * + * @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($row[$this->dataLimitField], $adminIds)) { + $this->error(__('You have no permission')); + } + + if (false === $this->request->isPost()) { + $this->view->assign('row', $row); + $this->view->assign('selectedCountries', $row->getSelectedCountries()); + return $this->fetchPatch('edit'); + } + + $params = $this->request->post('row/a', []); + if ($params === []) { + $this->error(__('Parameter %s can not be empty', '')); + } + + $params = $this->preExcludeFields($params); + unset($params['link_code']); + + $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(); + } + + /** + * 输出后台列表/表单 JS(patches 未部署到 public 时由 RequireJS 加载) + */ + public function script(): void + { + $jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js'; + if (!is_file($jsFile)) { + $jsFile = ROOT_PATH . 'public/assets/js/backend/split/link.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/link.php b/patches/application/admin/lang/zh-cn/split/link.php new file mode 100644 index 0000000..08e2eed --- /dev/null +++ b/patches/application/admin/lang/zh-cn/split/link.php @@ -0,0 +1,42 @@ + '投放国家', + 'Countries help' => '可多选,存储为 ISO 两位国家代码', + 'Link_code' => '分流链接', + 'Link code auto tip' => '保存后系统自动生成 9 位小写字母链接码', + 'Description' => '链接描述', + 'Ip_protect' => 'IP防护', + 'Random_shuffle' => '随机打乱', + 'Status' => '状态', + 'Createtime' => '创建时间', + 'Ip protect off' => '关闭', + 'Ip protect on' => '开启', + 'Random shuffle off' => '关闭', + 'Random shuffle on' => '开启', + 'Status normal' => '正常', + 'Status hidden' => '停用', + 'Copy split link' => '复制分流链接', + 'Manage my domains' => '管理我的域名', + 'Select main domain' => '选择主域名', + 'Platform assigned domain' => '平台分配域名', + 'My domains' => '我的域名', + 'Split platform domain empty' => '尚未配置平台分配域名,请前往', + 'Go system config' => '系统设置', + 'Split my domain empty' => '暂无域名,请先在域名管理中添加', + 'Split domain prefix tip' => '想使用自己的域名作为分流前缀,可以先去「我的域名」完成接入。', + 'Go add domain' => '去添加', + 'Generated result' => '生成结果', + 'Copy' => '复制', + 'Copy success' => '复制成功', + 'Copy failed' => '复制失败', + 'Open test' => '打开测试', + 'Close' => '关闭', + 'Split url empty' => '请先选择有效主域名', + 'System config' => '系统配置', + 'Auto reply' => '自动回复', + 'Reply statements' => '回复语句', + 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联', + 'Auto reply saved' => '自动回复已保存', + 'Reply statements column' => '回复语', +]; diff --git a/patches/application/admin/model/split/Link.php b/patches/application/admin/model/split/Link.php new file mode 100644 index 0000000..0773a4b --- /dev/null +++ b/patches/application/admin/model/split/Link.php @@ -0,0 +1,138 @@ + __('Ip protect off'), + '1' => __('Ip protect on'), + ]; + } + + public function getRandomShuffleList(): array + { + return [ + '0' => __('Random shuffle off'), + '1' => __('Random shuffle on'), + ]; + } + + public function getStatusList(): array + { + return [ + 'normal' => __('Status normal'), + 'hidden' => __('Status hidden'), + ]; + } + + public function setCountriesAttr($value): string + { + if (is_array($value)) { + return CountryIso::codesToStorage($value); + } + return CountryIso::codesToStorage(explode(',', (string)$value)); + } + + /** + * 分流链接码统一为小写 + */ + public function setLinkCodeAttr($value): string + { + return strtolower(trim((string) $value)); + } + + /** + * 自动回复:存储为一行一条(换行分隔) + * + * @param mixed $value + */ + public function setAutoReplyAttr($value): string + { + return \app\common\service\SplitAutoReplyService::formatStorage($value); + } + + /** + * 自动回复语句数组 + * + * @return array + */ + public function getAutoReplyLines(): array + { + return \app\common\service\SplitAutoReplyService::parseLines((string) $this->getAttr('auto_reply')); + } + + public function getCountriesTextAttr($value, $data): string + { + return CountryIso::codesToText((string)($data['countries'] ?? '')); + } + + /** + * 列表「回复语」列预览(最多 50 字 + ...,悬停用原始 auto_reply 换行展示) + */ + public function getAutoReplyTextAttr($value, $data): string + { + return \app\common\service\SplitAutoReplyService::previewForList((string) ($data['auto_reply'] ?? '')); + } + + public function getIpProtectTextAttr($value, $data): string + { + $value = $value !== '' && $value !== null ? $value : ($data['ip_protect'] ?? ''); + $list = $this->getIpProtectList(); + return $list[(string)$value] ?? ''; + } + + public function getRandomShuffleTextAttr($value, $data): string + { + $value = $value !== '' && $value !== null ? $value : ($data['random_shuffle'] ?? ''); + $list = $this->getRandomShuffleList(); + return $list[(string)$value] ?? ''; + } + + public function getStatusTextAttr($value, $data): string + { + $value = $value ?: ($data['status'] ?? ''); + $list = $this->getStatusList(); + return $list[$value] ?? ''; + } + + /** + * 已选国家 ISO 代码数组(供编辑表单多选回显) + * + * @return array + */ + public function getSelectedCountries(): array + { + $countries = (string)$this->getAttr('countries'); + if ($countries === '') { + return []; + } + return CountryIso::normalizeCodes(explode(',', $countries)); + } +} diff --git a/patches/application/admin/validate/split/Link.php b/patches/application/admin/validate/split/Link.php new file mode 100644 index 0000000..b08fa0f --- /dev/null +++ b/patches/application/admin/validate/split/Link.php @@ -0,0 +1,67 @@ + 'require|checkCountries', + 'link_code' => 'require|checkLinkCode', + 'description' => 'require|max:255', + 'ip_protect' => 'in:0,1', + 'random_shuffle' => 'in:0,1', + 'status' => 'in:normal,hidden', + ]; + + protected $message = [ + 'countries.require' => '请至少选择一个投放国家', + 'link_code.require' => '请填写分流链接', + 'description.require' => '请填写链接描述', + 'description.max' => '链接描述不能超过255个字符', + ]; + + protected $scene = [ + 'add' => ['countries', 'link_code', 'description', 'ip_protect', 'random_shuffle', 'status'], + 'edit' => ['countries', 'description', 'ip_protect', 'random_shuffle', 'status'], + ]; + + /** + * @param mixed $value + * @return bool|string + */ + protected function checkCountries($value) + { + $codes = is_array($value) ? $value : explode(',', (string)$value); + if (CountryIso::normalizeCodes($codes) === []) { + return '请至少选择一个有效的投放国家'; + } + return true; + } + + /** + * 校验分流链接格式与唯一性(仅新增) + * + * @param mixed $value + * @return bool|string + */ + protected function checkLinkCode($value) + { + $code = SplitLinkCodeService::normalize((string) $value); + if (!SplitLinkCodeService::isValidFormat($code)) { + return '分流链接须为9位小写字母(a-z)'; + } + if (!SplitLinkCodeService::isAvailable($code)) { + return '该分流链接已被使用,请更换'; + } + return true; + } +} diff --git a/patches/application/admin/view/split/link/add.html b/patches/application/admin/view/split/link/add.html new file mode 100644 index 0000000..de500ee --- /dev/null +++ b/patches/application/admin/view/split/link/add.html @@ -0,0 +1,69 @@ +
+ {:token()} + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ {foreach name="statusList" item="vo"} + + {/foreach} +
+
+
+ + +
diff --git a/patches/application/admin/view/split/link/edit.html b/patches/application/admin/view/split/link/edit.html new file mode 100644 index 0000000..e0d5b41 --- /dev/null +++ b/patches/application/admin/view/split/link/edit.html @@ -0,0 +1,70 @@ +
+ {:token()} + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ {foreach name="statusList" item="vo"} + + {/foreach} +
+
+
+ + +
diff --git a/patches/application/admin/view/split/link/index.html b/patches/application/admin/view/split/link/index.html new file mode 100644 index 0000000..122c8a2 --- /dev/null +++ b/patches/application/admin/view/split/link/index.html @@ -0,0 +1,46 @@ +
+ +
+ {:build_heading(null,FALSE)} + +
+ + +
+
+
+
+
+ + {:__('Add')} + {:__('Edit')} + {:__('Delete')} + + + + + +
+ +
+
+
+ +
+
+
diff --git a/patches/application/common/library/CountryIso.php b/patches/application/common/library/CountryIso.php new file mode 100644 index 0000000..e7261a4 --- /dev/null +++ b/patches/application/common/library/CountryIso.php @@ -0,0 +1,149 @@ + 中文名 + * + * @var array + */ + private const COUNTRIES = [ + 'CN' => '中国', + 'HK' => '中国香港', + 'MO' => '中国澳门', + 'TW' => '中国台湾', + 'US' => '美国', + 'CA' => '加拿大', + 'GB' => '英国', + 'DE' => '德国', + 'FR' => '法国', + 'IT' => '意大利', + 'ES' => '西班牙', + 'NL' => '荷兰', + 'BE' => '比利时', + 'CH' => '瑞士', + 'AT' => '奥地利', + 'SE' => '瑞典', + 'NO' => '挪威', + 'DK' => '丹麦', + 'FI' => '芬兰', + 'IE' => '爱尔兰', + 'PT' => '葡萄牙', + 'PL' => '波兰', + 'CZ' => '捷克', + 'HU' => '匈牙利', + 'RO' => '罗马尼亚', + 'GR' => '希腊', + 'RU' => '俄罗斯', + 'UA' => '乌克兰', + 'TR' => '土耳其', + 'IL' => '以色列', + 'SA' => '沙特阿拉伯', + 'AE' => '阿联酋', + 'QA' => '卡塔尔', + 'KW' => '科威特', + 'IN' => '印度', + 'PK' => '巴基斯坦', + 'BD' => '孟加拉国', + 'TH' => '泰国', + 'VN' => '越南', + 'MY' => '马来西亚', + 'SG' => '新加坡', + 'ID' => '印度尼西亚', + 'PH' => '菲律宾', + 'JP' => '日本', + 'KR' => '韩国', + 'AU' => '澳大利亚', + 'NZ' => '新西兰', + 'BR' => '巴西', + 'MX' => '墨西哥', + 'AR' => '阿根廷', + 'CL' => '智利', + 'CO' => '哥伦比亚', + 'PE' => '秘鲁', + 'ZA' => '南非', + 'EG' => '埃及', + 'NG' => '尼日利亚', + 'KE' => '肯尼亚', + ]; + + /** + * 下拉选项 ISO2 => 中文名 + * + * @return array + */ + public static function getOptions(): array + { + return self::COUNTRIES; + } + + /** + * 校验是否为合法 ISO2 代码 + */ + public static function isValidCode(string $code): bool + { + $code = strtoupper(trim($code)); + return isset(self::COUNTRIES[$code]); + } + + /** + * 规范化并过滤国家代码列表 + * + * @param array|string $codes + * @return array + */ + public static function normalizeCodes($codes): array + { + if (is_string($codes)) { + $codes = explode(',', $codes); + } + if (!is_array($codes)) { + return []; + } + $result = []; + foreach ($codes as $code) { + $code = strtoupper(trim((string)$code)); + if ($code !== '' && self::isValidCode($code) && !in_array($code, $result, true)) { + $result[] = $code; + } + } + sort($result); + return $result; + } + + /** + * 逗号分隔 ISO 代码转中文展示 + */ + public static function codesToText(string $codes): string + { + if ($codes === '') { + return ''; + } + $parts = []; + foreach (explode(',', $codes) as $code) { + $code = strtoupper(trim($code)); + if ($code === '') { + continue; + } + $parts[] = self::COUNTRIES[$code] ?? $code; + } + return implode(',', $parts); + } + + /** + * 数组转存储字符串 + * + * @param array $codes + */ + public static function codesToStorage(array $codes): string + { + return implode(',', self::normalizeCodes($codes)); + } +} diff --git a/patches/application/common/model/Config.php b/patches/application/common/model/Config.php new file mode 100644 index 0000000..47ce0fb --- /dev/null +++ b/patches/application/common/model/Config.php @@ -0,0 +1,242 @@ + 'json', + ]; + + /** + * 读取配置类型 + * @return array + */ + public static function getTypeList() + { + $typeList = [ + 'string' => __('String'), + 'password' => __('Password'), + 'text' => __('Text'), + 'editor' => __('Editor'), + 'number' => __('Number'), + 'date' => __('Date'), + 'time' => __('Time'), + 'datetime' => __('Datetime'), + 'datetimerange' => __('Datetimerange'), + 'select' => __('Select'), + 'selects' => __('Selects'), + 'image' => __('Image'), + 'images' => __('Images'), + 'file' => __('File'), + 'files' => __('Files'), + 'switch' => __('Switch'), + 'checkbox' => __('Checkbox'), + 'radio' => __('Radio'), + 'city' => __('City'), + 'selectpage' => __('Selectpage'), + 'selectpages' => __('Selectpages'), + 'array' => __('Array'), + 'custom' => __('Custom'), + ]; + return $typeList; + } + + public static function getRegexList() + { + $regexList = [ + 'required' => '必选', + 'digits' => '数字', + 'letters' => '字母', + 'date' => '日期', + 'time' => '时间', + 'email' => '邮箱', + 'url' => '网址', + 'qq' => 'QQ号', + 'IDcard' => '身份证', + 'tel' => '座机电话', + 'mobile' => '手机号', + 'zipcode' => '邮编', + 'chinese' => '中文', + 'username' => '用户名', + 'password' => '密码' + ]; + return $regexList; + } + + public function getExtendHtmlAttr($value, $data) + { + $result = preg_replace_callback("/\{([a-zA-Z]+)\}/", function ($matches) use ($data) { + if (isset($data[$matches[1]])) { + return $data[$matches[1]]; + } + }, $data['extend']); + return $result; + } + + /** + * 读取分类分组列表(优先数据库 configgroup,避免 site.php 未同步时分组缺失) + * + * @return array + */ + public static function getGroupList() + { + $groupList = config('site.configgroup'); + if (!is_array($groupList)) { + $groupList = []; + } + try { + $dbJson = \think\Db::name('config')->where('name', 'configgroup')->value('value'); + $dbList = json_decode((string) $dbJson, true); + if (is_array($dbList) && $dbList !== []) { + $groupList = $dbList; + } + } catch (\Throwable $e) { + // 数据库不可用时回退 site.php + } + foreach ($groupList as $k => &$v) { + $v = __($v); + } + return $groupList; + } + + public static function getArrayData($data) + { + if (!isset($data['value'])) { + $result = []; + foreach ($data as $index => $datum) { + $result['field'][$index] = $datum['key']; + $result['value'][$index] = $datum['value']; + } + $data = $result; + } + $fieldarr = $valuearr = []; + $field = $data['field'] ?? ($data['key'] ?? []); + $value = $data['value'] ?? []; + foreach ($field as $m => $n) { + if ($n != '') { + $fieldarr[] = $field[$m]; + $valuearr[] = $value[$m]; + } + } + return $fieldarr ? array_combine($fieldarr, $valuearr) : []; + } + + /** + * 将字符串解析成键值数组 + * @param string $text + * @return array + */ + public static function decode($text, $split = "\r\n") + { + $content = explode($split, $text); + $arr = []; + foreach ($content as $k => $v) { + if (stripos($v, "|") !== false) { + $item = explode('|', $v); + $arr[$item[0]] = $item[1]; + } + } + return $arr; + } + + /** + * 将键值数组转换为字符串 + * @param array $array + * @return string + */ + public static function encode($array, $split = "\r\n") + { + $content = ''; + if ($array && is_array($array)) { + $arr = []; + foreach ($array as $k => $v) { + $arr[] = "{$k}|{$v}"; + } + $content = implode($split, $arr); + } + return $content; + } + + /** + * 本地上传配置信息 + * @return array + */ + public static function upload() + { + $uploadcfg = config('upload'); + + $uploadurl = request()->module() ? $uploadcfg['uploadurl'] : ($uploadcfg['uploadurl'] === 'ajax/upload' ? 'index/' . $uploadcfg['uploadurl'] : $uploadcfg['uploadurl']); + + if (!preg_match("/^((?:[a-z]+:)?\/\/)(.*)/i", $uploadurl) && substr($uploadurl, 0, 1) !== '/') { + $uploadurl = url($uploadurl, '', false); + } + $uploadcfg['fullmode'] = isset($uploadcfg['fullmode']) && $uploadcfg['fullmode']; + $uploadcfg['thumbstyle'] = $uploadcfg['thumbstyle'] ?? ''; + + $upload = [ + 'cdnurl' => $uploadcfg['cdnurl'], + 'uploadurl' => $uploadurl, + 'bucket' => 'local', + 'maxsize' => $uploadcfg['maxsize'], + 'mimetype' => $uploadcfg['mimetype'], + 'chunking' => $uploadcfg['chunking'], + 'chunksize' => $uploadcfg['chunksize'], + 'savekey' => $uploadcfg['savekey'], + 'multipart' => [], + 'multiple' => $uploadcfg['multiple'], + 'fullmode' => $uploadcfg['fullmode'], + 'thumbstyle' => $uploadcfg['thumbstyle'], + 'storage' => 'local' + ]; + return $upload; + } + + /** + * 刷新配置文件 + */ + public static function refreshFile() + { + //如果没有配置权限无法进行修改 + if (!\app\admin\library\Auth::instance()->check('general/config/edit')) { + return false; + } + $config = []; + $configList = self::all(); + foreach ($configList as $k => $v) { + $value = $v->toArray(); + if (in_array($value['type'], ['selects', 'checkbox', 'images', 'files'])) { + $value['value'] = explode(',', $value['value']); + } + if ($value['type'] == 'array') { + $value['value'] = (array)json_decode($value['value'], true); + } + $config[$value['name']] = $value['value']; + } + file_put_contents( + CONF_PATH . 'extra' . DS . 'site.php', + ' + */ + public static function parseLines(string $raw): array + { + $raw = trim($raw); + if ($raw === '') { + return []; + } + $parts = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $lines = []; + foreach ($parts as $part) { + $line = trim((string) $part); + if ($line === '') { + continue; + } + if (strlen($line) > self::MAX_LINE_LENGTH) { + $line = mb_substr($line, 0, self::MAX_LINE_LENGTH, 'UTF-8'); + } + $lines[] = $line; + if (count($lines) >= self::MAX_LINES) { + break; + } + } + return $lines; + } + + /** + * 格式化为数据库存储(换行分隔) + * + * @param string|array $value + */ + public static function formatStorage($value): string + { + if (is_array($value)) { + $lines = $value; + } else { + $lines = self::parseLines((string) $value); + } + return implode("\n", $lines); + } + + /** + * 供表单 textarea 回显 + */ + public static function formatDisplay(string $stored): string + { + $lines = self::parseLines($stored); + return implode("\n", $lines); + } +} diff --git a/patches/application/common/service/SplitLinkCodeService.php b/patches/application/common/service/SplitLinkCodeService.php new file mode 100644 index 0000000..d1ea0a6 --- /dev/null +++ b/patches/application/common/service/SplitLinkCodeService.php @@ -0,0 +1,76 @@ + 0) { + $query->where('id', '<>', $excludeId); + } + return !$query->find(); + } + + /** + * 生成唯一 9 位小写字母链接码 + * + * @throws Exception + */ + public function generateUnique(): string + { + for ($i = 0; $i < self::MAX_ATTEMPTS; $i++) { + $code = $this->generateRandom(); + if (!Link::where('link_code', $code)->find()) { + return $code; + } + } + throw new Exception('分流链接生成失败,请稍后重试'); + } + + private function generateRandom(): string + { + $chars = self::CHARSET; + $max = strlen($chars) - 1; + $code = ''; + for ($i = 0; $i < self::CODE_LENGTH; $i++) { + $code .= $chars[random_int(0, $max)]; + } + return $code; + } +} diff --git a/patches/application/common/service/SplitPlatformDomainService.php b/patches/application/common/service/SplitPlatformDomainService.php new file mode 100644 index 0000000..1845dac --- /dev/null +++ b/patches/application/common/service/SplitPlatformDomainService.php @@ -0,0 +1,59 @@ + + */ + public static function parseList(string $raw): array + { + $raw = trim($raw); + if ($raw === '') { + return []; + } + + $parts = preg_split('/[\r\n,]+/', $raw) ?: []; + $domains = []; + foreach ($parts as $part) { + $domain = DomainModel::normalizeDomain(trim((string) $part)); + if ($domain === '' || isset($domains[$domain])) { + continue; + } + if (!DomainModel::isValidRootDomain($domain)) { + continue; + } + $domains[$domain] = $domain; + } + + return array_values($domains); + } + + /** + * 将域名列表格式化为配置存储文本(一行一个) + * + * @param array $domains + */ + public static function formatList(array $domains): string + { + $lines = []; + foreach ($domains as $domain) { + $domain = DomainModel::normalizeDomain((string) $domain); + if ($domain !== '' && DomainModel::isValidRootDomain($domain)) { + $lines[$domain] = $domain; + } + } + return implode("\n", array_values($lines)); + } +} diff --git a/patches/public/assets/js/backend/split/link.js b/patches/public/assets/js/backend/split/link.js new file mode 100644 index 0000000..704cb3f --- /dev/null +++ b/patches/public/assets/js/backend/split/link.js @@ -0,0 +1,550 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'split.link/index' + location.search, + add_url: 'split.link/add', + edit_url: 'split.link/edit', + del_url: 'split.link/del', + multi_url: 'split.link/multi', + table: 'split_link', + } + }); + + 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: 'countries_text', title: __('Countries'), operate: false, formatter: Table.api.formatter.content}, + { + field: 'link_code', + title: __('Link_code'), + operate: 'LIKE', + formatter: Controller.api.formatter.linkCode + }, + {field: 'description', title: __('Description'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content}, + { + field: 'auto_reply_text', + title: __('Reply statements column'), + operate: false, + formatter: Controller.api.formatter.autoReplyText + }, + {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, + {field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status}, + {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, + buttons: [ + { + name: 'autoreply', + text: __('Auto reply'), + title: __('Auto reply'), + icon: 'fa fa-commenting-o', + classname: 'btn btn-warning btn-xs btn-split-autoreply', + url: 'javascript:;' + } + ], + formatter: Table.api.formatter.operate + } + ] + ] + }); + + table.on('click', '.btn-copy-split-link', function (e) { + e.preventDefault(); + e.stopPropagation(); + var linkCode = $.trim($(this).data('link-code') || ''); + if (!linkCode) { + return false; + } + Controller.api.openCopyModal(linkCode); + }); + + table.on('click', '.btn-copy-link-code-inline', function (e) { + e.preventDefault(); + e.stopPropagation(); + var linkCode = $.trim($(this).data('link-code') || ''); + if (linkCode) { + Controller.api.copyText(linkCode); + } + }); + + table.on('click', '.btn-split-autoreply', function (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var rowIndex = $(this).data('row-index'); + var row = Table.api.getrowbyindex(table, rowIndex); + if (!row || !row.id) { + return false; + } + Controller.api.openAutoReplyModal(row); + }); + + Controller.api.bindAutoReplyPreviewTips(table); + + Table.api.bindevent(table); + }, + add: function () { + Controller.api.bindevent(); + Controller.api.bindLinkCodeInput(); + }, + edit: function () { + Controller.api.bindevent(); + }, + api: { + /** 弹窗样式(仅注入一次) */ + modalStyleInjected: false, + injectModalStyles: function () { + if (Controller.api.modalStyleInjected) { + return; + } + Controller.api.modalStyleInjected = true; + var css = [ + '.split-link-copy-modal{padding:16px 20px;box-sizing:border-box;}', + '.split-link-copy-modal .form-group{margin-bottom:16px;}', + '.split-link-copy-modal .control-label{display:block;font-weight:600;color:#444;margin-bottom:8px;}', + '.split-link-copy-modal .split-link-code-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;}', + '.split-link-copy-modal .split-link-code-value{font-size:18px;font-weight:600;color:#337ab7;text-decoration:underline;word-break:break-all;}', + '.split-link-copy-modal .split-domain-tabs{margin-bottom:0;border-bottom:1px solid #ddd;}', + '.split-link-copy-modal .split-domain-tab-box{border:1px solid #ddd;border-top:none;border-radius:0 0 4px 4px;background:#fafafa;overflow:hidden;}', + '.split-link-copy-modal .split-domain-tab-box .tab-pane{max-height:280px;overflow-y:auto;overflow-x:hidden;padding:10px 12px;margin:0;}', + '.split-link-copy-modal .split-domain-list{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;align-items:stretch;}', + '.split-link-copy-modal .split-domain-item{border:1px solid #e8e8e8;border-radius:4px;background:#fff;transition:border-color .2s,box-shadow .2s;height:100%;min-width:0;}', + '.split-link-copy-modal .split-domain-item:hover{border-color:#b8d4f0;}', + '.split-link-copy-modal .split-domain-item.is-checked{border-color:#337ab7;box-shadow:0 0 0 1px rgba(51,122,183,.15);}', + '.split-link-copy-modal .split-domain-item-label{display:flex;align-items:flex-start;gap:8px;margin:0;padding:8px 10px;cursor:pointer;font-weight:normal;width:100%;height:100%;box-sizing:border-box;}', + '.split-link-copy-modal .split-domain-item-label input[type=radio]{margin:3px 0 0;flex-shrink:0;}', + '.split-link-copy-modal .split-domain-item-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px;}', + '.split-link-copy-modal .split-domain-name{font-size:13px;font-weight:600;color:#333;word-break:break-all;line-height:1.4;}', + '.split-link-copy-modal .split-domain-status{display:flex;flex-direction:column;align-items:flex-start;gap:4px;}', + '.split-link-copy-modal .split-domain-status .label{font-size:10px;font-weight:normal;padding:2px 6px;line-height:1.3;white-space:nowrap;margin:0;display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;}', + '.split-link-copy-modal .split-domain-empty{margin:0;padding:20px 12px;color:#999;text-align:center;line-height:1.6;grid-column:1/-1;}', + '.split-link-copy-modal .split-tip-box{margin:12px 0;padding:10px 12px;background:#f8f9fa;border-left:3px solid #337ab7;border-radius:0 4px 4px 0;line-height:1.6;color:#666;}', + '.split-link-copy-modal .split-generated-url{font-size:12px;}', + '.split-link-copy-modal .split-modal-footer{margin-top:16px;padding-top:12px;border-top:1px solid #eee;}' + ].join(''); + $('').text(css).appendTo('head'); + }, + formatter: { + /** + * 回复语:列表最多 50 字 + ...,悬停保留换行显示全文 + */ + autoReplyText: function (value, row, index) { + var full = (row.auto_reply != null && row.auto_reply !== '') ? String(row.auto_reply) : ''; + if (full === '') { + return '-'; + } + var preview = value != null && value !== '' ? String(value) : full; + var safePreview = Fast.api.escape(preview); + var encFull = encodeURIComponent(full); + return '' + + safePreview + ''; + }, + /** + * 分流链接列:链接样式 + COPY 图标,点击链接打开弹窗 + */ + linkCode: function (value) { + value = value == null ? '' : value.toString(); + if (value === '') { + return '-'; + } + var safe = Fast.api.escape(value); + return '' + + '' + safe + '' + + ' ' + + ''; + } + }, + openCopyModal: function (linkCode) { + Controller.api.loadCopyModalData(function (data) { + Controller.api.renderCopyModal(linkCode, data); + }); + }, + /** 回复语列悬停提示(保留换行) */ + autoReplyTipIndex: null, + bindAutoReplyPreviewTips: function (table) { + table.off('mouseenter.splitAutoReply mouseleave.splitAutoReply') + .on('mouseenter.splitAutoReply', '.split-auto-reply-preview', function () { + var enc = $(this).attr('data-full'); + if (!enc) { + return; + } + var full = ''; + try { + full = decodeURIComponent(enc); + } catch (e) { + return; + } + var html = '
' + + Controller.api.escapeHtmlWithNewlines(full) + '
'; + Controller.api.autoReplyTipIndex = Layer.tips(html, this, { + tips: [1, ''], + time: 0, + maxWidth: 450 + }); + }) + .on('mouseleave.splitAutoReply', '.split-auto-reply-preview', function () { + if (Controller.api.autoReplyTipIndex != null) { + Layer.close(Controller.api.autoReplyTipIndex); + Controller.api.autoReplyTipIndex = null; + } + }); + }, + escapeHtmlWithNewlines: function (text) { + return $('
').text(text).html().replace(/\n/g, '
'); + }, + /** + * 打开自动回复弹窗 + * + * @param {object} row 列表行数据 + */ + openAutoReplyModal: function (row) { + Fast.api.ajax({ + url: 'split.link/autoreply/ids/' + row.id, + type: 'get' + }, function (data, ret) { + var info = ret.data || data || {}; + Controller.api.renderAutoReplyModal(row.id, info); + return false; + }); + }, + renderAutoReplyModal: function (linkId, info) { + var linkCode = Fast.api.escape(info.link_code || ''); + var autoReply = info.auto_reply || ''; + var html = [ + '
', + '
', + ' ', + '

' + linkCode + '

', + '
', + '
', + ' ', + '

' + __('Reply statements tip') + '

', + ' ', + '
', + '
' + ].join(''); + + Layer.open({ + type: 1, + title: __('Auto reply'), + area: ['520px', 'auto'], + shadeClose: false, + content: html, + btn: [__('OK'), __('Close')], + yes: function (index, layero) { + var text = layero.find('#split-auto-reply-text').val(); + Fast.api.ajax({ + url: 'split.link/autoreply/ids/' + linkId, + type: 'post', + data: {auto_reply: text} + }, function () { + Layer.close(index); + Toastr.success(__('Auto reply saved')); + }); + return false; + } + }); + }, + loadCopyModalData: function (callback) { + Fast.api.ajax({ + url: 'split.link/copyinfo', + type: 'get' + }, function (data, ret) { + callback(ret.data || data || {}); + return false; + }); + }, + renderCopyModal: function (linkCode, data) { + Controller.api.injectModalStyles(); + + var platformDomains = $.isArray(data.platform_domains) ? data.platform_domains : []; + if (!platformDomains.length && data.platform_domain) { + var rawSingle = $.trim(data.platform_domain); + platformDomains = rawSingle.split(/[\r\n,]+/).map(function (d) { + return $.trim(d); + }).filter(function (d) { + return d.length > 0; + }); + } + var myDomains = $.isArray(data.my_domains) ? data.my_domains : []; + var domainIndexUrl = data.domain_index_url || 'domain/index'; + var configIndexUrl = data.config_index_url || 'general/config/index'; + var defaultType = platformDomains.length ? 'platform' : 'my'; + var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : ''); + var activeTab = defaultType === 'platform' ? 'platform' : 'my'; + + var platformHtml = ''; + if (platformDomains.length) { + var platformItems = []; + $.each(platformDomains, function (i, domain) { + platformItems.push(Controller.api.buildDomainRadio({ + domain: domain, + type: 'platform', + checked: defaultType === 'platform' && domain === defaultDomain, + showStatus: false + })); + }); + platformHtml = '
' + platformItems.join('') + '
'; + } else { + platformHtml = '

' + + __('Split platform domain empty') + + ' ' + __('Go system config') + '' + + '

'; + } + + var myDomainsHtml = ''; + if (myDomains.length) { + var items = []; + $.each(myDomains, function (i, item) { + items.push(Controller.api.buildDomainRadio({ + domain: item.domain, + type: 'my', + checked: defaultType === 'my' && item.domain === defaultDomain, + nsText: item.ns_status_text || '', + dnsText: item.dns_status_text || '', + nsStatus: item.ns_status || '', + dnsStatus: item.dns_status || '', + showStatus: true + })); + }); + myDomainsHtml = '
' + items.join('') + '
'; + } else { + myDomainsHtml = '

' + __('Split my domain empty') + '

'; + } + + var initialUrl = Controller.api.buildSplitUrl(defaultDomain, linkCode); + var safeLinkCode = Fast.api.escape(linkCode); + var html = [ + '' + ].join(''); + + Layer.open({ + type: 1, + title: __('Copy split link'), + area: ['780px', 'auto'], + maxmin: false, + resize: false, + shadeClose: true, + content: html, + success: function (layero, index) { + var $box = layero.find('.split-link-copy-modal'); + + var refreshGeneratedUrl = function () { + var $checked = $box.find('input[name="split_main_domain"]:checked'); + var domain = $.trim($checked.val() || ''); + var url = Controller.api.buildSplitUrl(domain, linkCode); + $box.find('.split-generated-url').val(url); + $box.find('.btn-open-test').prop('disabled', url === ''); + $box.find('.split-domain-item').removeClass('is-checked'); + $checked.closest('.split-domain-item').addClass('is-checked'); + }; + + var ensureTabSelection = function ($pane) { + var $radios = $pane.find('input[name="split_main_domain"]'); + if ($radios.length && !$radios.filter(':checked').length) { + $radios.first().prop('checked', true); + } + }; + + $box.on('change', 'input[name="split_main_domain"]', refreshGeneratedUrl); + + $box.on('shown.bs.tab', 'a[data-toggle="tab"]', function (e) { + var target = $(e.target).attr('href'); + if (target) { + ensureTabSelection($box.find(target)); + refreshGeneratedUrl(); + } + }); + + $box.on('click', '.btn-copy-link-code', function (e) { + e.preventDefault(); + Controller.api.copyText(linkCode); + }); + + $box.on('click', '.btn-manage-domain, .btn-go-add-domain', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Manage my domains'), 'fa fa-globe'); + }); + + $box.on('click', '.btn-goto-config', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(configIndexUrl), __('System config'), 'fa fa-cogs'); + }); + + $box.on('click', '.btn-copy-generated-url', function (e) { + e.preventDefault(); + var url = $.trim($box.find('.split-generated-url').val()); + if (!url) { + Toastr.error(__('Split url empty')); + return; + } + Controller.api.copyText(url); + }); + + $box.on('click', '.btn-open-test', function (e) { + e.preventDefault(); + var url = $.trim($box.find('.split-generated-url').val()); + if (url) { + window.open(url, '_blank'); + } + }); + + $box.on('click', '.btn-close-copy-modal', function (e) { + e.preventDefault(); + Layer.close(index); + }); + + ensureTabSelection($box.find('.tab-pane.active')); + refreshGeneratedUrl(); + } + }); + }, + buildDomainRadio: function (options) { + var domain = Fast.api.escape(options.domain || ''); + var type = options.type || 'my'; + var checked = options.checked ? ' checked' : ''; + var checkedClass = options.checked ? ' is-checked' : ''; + var statusHtml = ''; + if (options.showStatus) { + statusHtml = '' + + 'NS:' + Fast.api.escape(options.nsText || '-') + '' + + 'DNS:' + Fast.api.escape(options.dnsText || '-') + '' + + ''; + } + return '
' + + '' + + '
'; + }, + statusLabelClass: function (status, isDns) { + if (isDns) { + if (status === 'created') { + return 'label-success'; + } + if (status === 'failed') { + return 'label-danger'; + } + return 'label-warning'; + } + if (status === 'verified') { + return 'label-success'; + } + if (status === 'failed') { + return 'label-danger'; + } + return 'label-warning'; + }, + buildSplitUrl: function (domain, linkCode) { + domain = $.trim(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + linkCode = $.trim(linkCode || ''); + if (!domain || !linkCode) { + return ''; + } + return 'https://' + domain + '/s/' + linkCode; + }, + copyText: function (text) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(function () { + Toastr.success(__('Copy success')); + }).catch(function () { + Controller.api.copyTextFallback(text); + }); + return; + } + Controller.api.copyTextFallback(text); + }, + copyTextFallback: function (text) { + var $temp = $('', + '
', + '' + ].join(''); + + Layer.open({ + type: 1, + title: __('Auto reply'), + area: ['520px', 'auto'], + shadeClose: false, + content: html, + btn: [__('OK'), __('Close')], + yes: function (index, layero) { + var text = layero.find('#split-auto-reply-text').val(); + Fast.api.ajax({ + url: 'split.link/autoreply/ids/' + linkId, + type: 'post', + data: {auto_reply: text} + }, function () { + Layer.close(index); + Toastr.success(__('Auto reply saved')); + }); + return false; + } + }); + }, + loadCopyModalData: function (callback) { + Fast.api.ajax({ + url: 'split.link/copyinfo', + type: 'get' + }, function (data, ret) { + callback(ret.data || data || {}); + return false; + }); + }, + renderCopyModal: function (linkCode, data) { + Controller.api.injectModalStyles(); + + var platformDomains = $.isArray(data.platform_domains) ? data.platform_domains : []; + if (!platformDomains.length && data.platform_domain) { + var rawSingle = $.trim(data.platform_domain); + platformDomains = rawSingle.split(/[\r\n,]+/).map(function (d) { + return $.trim(d); + }).filter(function (d) { + return d.length > 0; + }); + } + var myDomains = $.isArray(data.my_domains) ? data.my_domains : []; + var domainIndexUrl = data.domain_index_url || 'domain/index'; + var configIndexUrl = data.config_index_url || 'general/config/index'; + var defaultType = platformDomains.length ? 'platform' : 'my'; + var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : ''); + var activeTab = defaultType === 'platform' ? 'platform' : 'my'; + + var platformHtml = ''; + if (platformDomains.length) { + var platformItems = []; + $.each(platformDomains, function (i, domain) { + platformItems.push(Controller.api.buildDomainRadio({ + domain: domain, + type: 'platform', + checked: defaultType === 'platform' && domain === defaultDomain, + showStatus: false + })); + }); + platformHtml = '
' + platformItems.join('') + '
'; + } else { + platformHtml = '

' + + __('Split platform domain empty') + + ' ' + __('Go system config') + '' + + '

'; + } + + var myDomainsHtml = ''; + if (myDomains.length) { + var items = []; + $.each(myDomains, function (i, item) { + items.push(Controller.api.buildDomainRadio({ + domain: item.domain, + type: 'my', + checked: defaultType === 'my' && item.domain === defaultDomain, + nsText: item.ns_status_text || '', + dnsText: item.dns_status_text || '', + nsStatus: item.ns_status || '', + dnsStatus: item.dns_status || '', + showStatus: true + })); + }); + myDomainsHtml = '
' + items.join('') + '
'; + } else { + myDomainsHtml = '

' + __('Split my domain empty') + '

'; + } + + var initialUrl = Controller.api.buildSplitUrl(defaultDomain, linkCode); + var safeLinkCode = Fast.api.escape(linkCode); + var html = [ + '' + ].join(''); + + Layer.open({ + type: 1, + title: __('Copy split link'), + area: ['780px', 'auto'], + maxmin: false, + resize: false, + shadeClose: true, + content: html, + success: function (layero, index) { + var $box = layero.find('.split-link-copy-modal'); + + var refreshGeneratedUrl = function () { + var $checked = $box.find('input[name="split_main_domain"]:checked'); + var domain = $.trim($checked.val() || ''); + var url = Controller.api.buildSplitUrl(domain, linkCode); + $box.find('.split-generated-url').val(url); + $box.find('.btn-open-test').prop('disabled', url === ''); + $box.find('.split-domain-item').removeClass('is-checked'); + $checked.closest('.split-domain-item').addClass('is-checked'); + }; + + var ensureTabSelection = function ($pane) { + var $radios = $pane.find('input[name="split_main_domain"]'); + if ($radios.length && !$radios.filter(':checked').length) { + $radios.first().prop('checked', true); + } + }; + + $box.on('change', 'input[name="split_main_domain"]', refreshGeneratedUrl); + + $box.on('shown.bs.tab', 'a[data-toggle="tab"]', function (e) { + var target = $(e.target).attr('href'); + if (target) { + ensureTabSelection($box.find(target)); + refreshGeneratedUrl(); + } + }); + + $box.on('click', '.btn-copy-link-code', function (e) { + e.preventDefault(); + Controller.api.copyText(linkCode); + }); + + $box.on('click', '.btn-manage-domain, .btn-go-add-domain', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Manage my domains'), 'fa fa-globe'); + }); + + $box.on('click', '.btn-goto-config', function (e) { + e.preventDefault(); + Layer.close(index); + Backend.api.addtabs(Fast.api.fixurl(configIndexUrl), __('System config'), 'fa fa-cogs'); + }); + + $box.on('click', '.btn-copy-generated-url', function (e) { + e.preventDefault(); + var url = $.trim($box.find('.split-generated-url').val()); + if (!url) { + Toastr.error(__('Split url empty')); + return; + } + Controller.api.copyText(url); + }); + + $box.on('click', '.btn-open-test', function (e) { + e.preventDefault(); + var url = $.trim($box.find('.split-generated-url').val()); + if (url) { + window.open(url, '_blank'); + } + }); + + $box.on('click', '.btn-close-copy-modal', function (e) { + e.preventDefault(); + Layer.close(index); + }); + + ensureTabSelection($box.find('.tab-pane.active')); + refreshGeneratedUrl(); + } + }); + }, + buildDomainRadio: function (options) { + var domain = Fast.api.escape(options.domain || ''); + var type = options.type || 'my'; + var checked = options.checked ? ' checked' : ''; + var checkedClass = options.checked ? ' is-checked' : ''; + var statusHtml = ''; + if (options.showStatus) { + statusHtml = '' + + 'NS:' + Fast.api.escape(options.nsText || '-') + '' + + 'DNS:' + Fast.api.escape(options.dnsText || '-') + '' + + ''; + } + return '
' + + '' + + '
'; + }, + statusLabelClass: function (status, isDns) { + if (isDns) { + if (status === 'created') { + return 'label-success'; + } + if (status === 'failed') { + return 'label-danger'; + } + return 'label-warning'; + } + if (status === 'verified') { + return 'label-success'; + } + if (status === 'failed') { + return 'label-danger'; + } + return 'label-warning'; + }, + buildSplitUrl: function (domain, linkCode) { + domain = $.trim(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + linkCode = $.trim(linkCode || ''); + if (!domain || !linkCode) { + return ''; + } + return 'https://' + domain + '/s/' + linkCode; + }, + copyText: function (text) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(function () { + Toastr.success(__('Copy success')); + }).catch(function () { + Controller.api.copyTextFallback(text); + }); + return; + } + Controller.api.copyTextFallback(text); + }, + copyTextFallback: function (text) { + var $temp = $('