链接管理模块

This commit is contained in:
root
2026-06-03 12:10:25 +08:00
parent 4aec0b4fce
commit 907d78b3aa
38 changed files with 3805 additions and 1 deletions
@@ -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';
@@ -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);
@@ -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;
+367
View File
@@ -0,0 +1,367 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\split;
use app\admin\model\Domain as DomainModel;
use app\common\controller\Backend;
use app\common\library\CountryIso;
use app\common\service\SplitAutoReplyService;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitPlatformDomainService;
use think\Db;
use think\Exception;
use think\exception\DbException;
use think\exception\PDOException;
use think\exception\ValidateException;
use think\response\Json;
/**
* 链接管理
*
* @icon fa fa-link
* @remark 分流链接管理,自动生成9位字母链接码
*/
class Link extends Backend
{
/** @var \app\admin\model\split\Link */
protected $model = null;
protected $searchFields = 'link_code,description';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
/** @var string[] 无需鉴权的操作 */
protected $noNeedRight = ['script', 'copyinfo'];
/** @var string patches 视图目录(未完整部署时使用) */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/link/';
public function _initialize()
{
parent::_initialize();
$lang = $this->request->langset();
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/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<int, string>
*/
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();
}
/**
* 输出后台列表/表单 JSpatches 未部署到 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);
}
}
@@ -0,0 +1,42 @@
<?php
return [
'Countries' => '投放国家',
'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' => '回复语',
];
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace app\admin\model\split;
use app\common\library\CountryIso;
use think\Model;
/**
* 分流链接模型
*/
class Link extends Model
{
protected $name = 'split_link';
protected $autoWriteTimestamp = 'integer';
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $deleteTime = false;
protected $append = [
'countries_text',
'auto_reply_text',
'ip_protect_text',
'random_shuffle_text',
'status_text',
];
public function getIpProtectList(): array
{
return [
'0' => __('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<int, string>
*/
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<int, string>
*/
public function getSelectedCountries(): array
{
$countries = (string)$this->getAttr('countries');
if ($countries === '') {
return [];
}
return CountryIso::normalizeCodes(explode(',', $countries));
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\admin\validate\split;
use app\common\library\CountryIso;
use app\common\service\SplitLinkCodeService;
use think\Validate;
/**
* 分流链接验证器
*/
class Link extends Validate
{
protected $rule = [
'countries' => '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;
}
}
@@ -0,0 +1,69 @@
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
{:token()}
<div class="form-group">
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
{foreach name="countryList" item="vo" key="code"}
<option value="{$code|htmlentities}">{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-link_code" data-rule="required" class="form-control" name="row[link_code]" type="text" value="{$defaultLinkCode|htmlentities}" maxlength="9" autocomplete="off">
</div>
</div>
<div class="form-group">
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="" maxlength="255">
</div>
</div>
<div class="form-group">
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
{foreach name="ipProtectList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
{foreach name="randomShuffleList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
<div class="col-xs-12 col-sm-8">
<div class="radio">
{foreach name="statusList" item="vo"}
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="normal"}checked{/in} /> {$vo|htmlentities}</label>
{/foreach}
</div>
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
</div>
</div>
</form>
@@ -0,0 +1,70 @@
<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
{:token()}
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
<div class="form-group">
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
{foreach name="countryList" item="vo" key="code"}
<option value="{$code|htmlentities}" {in name="code" value="$selectedCountries"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-link_code" class="form-control" type="text" value="{$row.link_code|htmlentities}" readonly>
</div>
</div>
<div class="form-group">
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="{$row.description|htmlentities}" maxlength="255">
</div>
</div>
<div class="form-group">
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
{foreach name="ipProtectList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="$row.ip_protect"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
{foreach name="randomShuffleList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="$row.random_shuffle"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
<div class="col-xs-12 col-sm-8">
<div class="radio">
{foreach name="statusList" item="vo"}
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="$row.status"}checked{/in} /> {$vo|htmlentities}</label>
{/foreach}
</div>
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
</div>
</div>
</form>
@@ -0,0 +1,46 @@
<div class="panel panel-default panel-intro">
<div class="panel-heading">
{:build_heading(null,FALSE)}
<ul class="nav nav-tabs" data-field="status">
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
{foreach name="statusList" item="vo"}
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
{/foreach}
</ul>
</div>
<div class="panel-body">
<div id="myTabContent" class="tab-content">
<div class="tab-pane fade active in" id="one">
<div class="widget-body no-padding">
<div id="toolbar" class="toolbar">
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}" ><i class="fa fa-refresh"></i> </a>
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.link/add')?'':'hide'}" title="{:__('Add')}" ><i class="fa fa-plus"></i> {:__('Add')}</a>
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.link/edit')?'':'hide'}" title="{:__('Edit')}" ><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.link/del')?'':'hide'}" title="{:__('Delete')}" ><i class="fa fa-trash"></i> {:__('Delete')}</a>
<div class="dropdown btn-group {:$auth->check('split.link/multi')?'':'hide'}">
<a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
<ul class="dropdown-menu text-left" role="menu">
{foreach name="statusList" item="vo"}
<li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:" data-params="status={$key}">{:__('Set status to ' . $key)}</a></li>
{/foreach}
</ul>
</div>
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="{:$auth->check('split.link/edit')}"
data-operate-del="{:$auth->check('split.link/del')}"
width="100%">
</table>
</div>
</div>
</div>
</div>
</div>
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* ISO 3166-1 alpha-2 国家代码与中文名称
*/
class CountryIso
{
/**
* 常用投放国家 ISO2 => 中文名
*
* @var array<string, string>
*/
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<string, string>
*/
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<int, string>|string $codes
* @return array<int, string>
*/
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<int, string> $codes
*/
public static function codesToStorage(array $codes): string
{
return implode(',', self::normalizeCodes($codes));
}
}
+16 -1
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use think\Model;
@@ -92,12 +94,25 @@ class Config extends Model
}
/**
* 读取分类分组列表
* 读取分类分组列表(优先数据库 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);
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接自动回复语句(一行一条)
*/
class SplitAutoReplyService
{
/** 最多条数 */
private const MAX_LINES = 200;
/** 单条最大字符数 */
private const MAX_LINE_LENGTH = 500;
/**
* 解析为多行文本数组
*
* @return array<int, string>
*/
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<int, string> $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);
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use think\Exception;
/**
* 分流链接码生成服务
*/
class SplitLinkCodeService
{
private const CODE_LENGTH = 9;
private const CHARSET = 'abcdefghijklmnopqrstuvwxyz';
private const MAX_ATTEMPTS = 50;
/**
* 规范化链接码(小写、去空格)
*/
public static function normalize(string $code): string
{
return strtolower(trim($code));
}
/**
* 是否为合法 9 位小写字母链接码
*/
public static function isValidFormat(string $code): bool
{
return (bool) preg_match('/^[a-z]{9}$/', $code);
}
/**
* 链接码是否未被占用
*/
public static function isAvailable(string $code, int $excludeId = 0): bool
{
$code = self::normalize($code);
$query = Link::where('link_code', $code);
if ($excludeId > 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;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\Domain as DomainModel;
/**
* 平台分配域名解析(支持多域名,一行一个)
*/
class SplitPlatformDomainService
{
/**
* 解析配置文本为合法根域名列表(去重、小写)
*
* @param string $raw 换行或逗号分隔的域名文本
* @return array<int, string>
*/
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<int, string> $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));
}
}
+5
View File
@@ -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',
);
+13
View File
@@ -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 "菜单已写入数据库,请在权限管理中勾选「分流管理」相关节点。"
View File
+19
View File
@@ -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 "完成。请刷新后台「常规管理 -> 系统配置」,应能看到「分流设置」标签及「平台分配域名」字段。"
@@ -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&#10;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&#10;another.com"'
WHERE `name` = 'split_platform_domain';
@@ -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);
@@ -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;
@@ -0,0 +1,367 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\split;
use app\admin\model\Domain as DomainModel;
use app\common\controller\Backend;
use app\common\library\CountryIso;
use app\common\service\SplitAutoReplyService;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitPlatformDomainService;
use think\Db;
use think\Exception;
use think\exception\DbException;
use think\exception\PDOException;
use think\exception\ValidateException;
use think\response\Json;
/**
* 链接管理
*
* @icon fa fa-link
* @remark 分流链接管理,自动生成9位字母链接码
*/
class Link extends Backend
{
/** @var \app\admin\model\split\Link */
protected $model = null;
protected $searchFields = 'link_code,description';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
/** @var string[] 无需鉴权的操作 */
protected $noNeedRight = ['script', 'copyinfo'];
/** @var string patches 视图目录(未完整部署时使用) */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/link/';
public function _initialize()
{
parent::_initialize();
$lang = $this->request->langset();
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/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<int, string>
*/
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();
}
/**
* 输出后台列表/表单 JSpatches 未部署到 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);
}
}
@@ -0,0 +1,42 @@
<?php
return [
'Countries' => '投放国家',
'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' => '回复语',
];
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace app\admin\model\split;
use app\common\library\CountryIso;
use think\Model;
/**
* 分流链接模型
*/
class Link extends Model
{
protected $name = 'split_link';
protected $autoWriteTimestamp = 'integer';
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $deleteTime = false;
protected $append = [
'countries_text',
'auto_reply_text',
'ip_protect_text',
'random_shuffle_text',
'status_text',
];
public function getIpProtectList(): array
{
return [
'0' => __('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<int, string>
*/
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<int, string>
*/
public function getSelectedCountries(): array
{
$countries = (string)$this->getAttr('countries');
if ($countries === '') {
return [];
}
return CountryIso::normalizeCodes(explode(',', $countries));
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\admin\validate\split;
use app\common\library\CountryIso;
use app\common\service\SplitLinkCodeService;
use think\Validate;
/**
* 分流链接验证器
*/
class Link extends Validate
{
protected $rule = [
'countries' => '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;
}
}
@@ -0,0 +1,69 @@
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
{:token()}
<div class="form-group">
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
{foreach name="countryList" item="vo" key="code"}
<option value="{$code|htmlentities}">{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-link_code" data-rule="required" class="form-control" name="row[link_code]" type="text" value="{$defaultLinkCode|htmlentities}" maxlength="9" autocomplete="off">
</div>
</div>
<div class="form-group">
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="" maxlength="255">
</div>
</div>
<div class="form-group">
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
{foreach name="ipProtectList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
{foreach name="randomShuffleList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
<div class="col-xs-12 col-sm-8">
<div class="radio">
{foreach name="statusList" item="vo"}
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="normal"}checked{/in} /> {$vo|htmlentities}</label>
{/foreach}
</div>
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
</div>
</div>
</form>
@@ -0,0 +1,70 @@
<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
{:token()}
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
<div class="form-group">
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
{foreach name="countryList" item="vo" key="code"}
<option value="{$code|htmlentities}" {in name="code" value="$selectedCountries"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-link_code" class="form-control" type="text" value="{$row.link_code|htmlentities}" readonly>
</div>
</div>
<div class="form-group">
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="{$row.description|htmlentities}" maxlength="255">
</div>
</div>
<div class="form-group">
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
{foreach name="ipProtectList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="$row.ip_protect"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
{foreach name="randomShuffleList" item="vo"}
<option value="{$key|htmlentities}" {in name="key" value="$row.random_shuffle"}selected{/in}>{$vo|htmlentities}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
<div class="col-xs-12 col-sm-8">
<div class="radio">
{foreach name="statusList" item="vo"}
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="$row.status"}checked{/in} /> {$vo|htmlentities}</label>
{/foreach}
</div>
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
</div>
</div>
</form>
@@ -0,0 +1,46 @@
<div class="panel panel-default panel-intro">
<div class="panel-heading">
{:build_heading(null,FALSE)}
<ul class="nav nav-tabs" data-field="status">
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
{foreach name="statusList" item="vo"}
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
{/foreach}
</ul>
</div>
<div class="panel-body">
<div id="myTabContent" class="tab-content">
<div class="tab-pane fade active in" id="one">
<div class="widget-body no-padding">
<div id="toolbar" class="toolbar">
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}" ><i class="fa fa-refresh"></i> </a>
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.link/add')?'':'hide'}" title="{:__('Add')}" ><i class="fa fa-plus"></i> {:__('Add')}</a>
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.link/edit')?'':'hide'}" title="{:__('Edit')}" ><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.link/del')?'':'hide'}" title="{:__('Delete')}" ><i class="fa fa-trash"></i> {:__('Delete')}</a>
<div class="dropdown btn-group {:$auth->check('split.link/multi')?'':'hide'}">
<a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
<ul class="dropdown-menu text-left" role="menu">
{foreach name="statusList" item="vo"}
<li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:" data-params="status={$key}">{:__('Set status to ' . $key)}</a></li>
{/foreach}
</ul>
</div>
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="{:$auth->check('split.link/edit')}"
data-operate-del="{:$auth->check('split.link/del')}"
width="100%">
</table>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* ISO 3166-1 alpha-2 国家代码与中文名称
*/
class CountryIso
{
/**
* 常用投放国家 ISO2 => 中文名
*
* @var array<string, string>
*/
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<string, string>
*/
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<int, string>|string $codes
* @return array<int, string>
*/
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<int, string> $codes
*/
public static function codesToStorage(array $codes): string
{
return implode(',', self::normalizeCodes($codes));
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use think\Model;
/**
* 配置模型
*/
class Config extends Model
{
// 表名,不含前缀
protected $name = 'config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = false;
// 定义时间戳字段名
protected $createTime = false;
protected $updateTime = false;
// 追加属性
protected $append = [
'extend_html'
];
protected $type = [
'setting' => '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',
'<?php' . "\n\nreturn " . var_export($config, true) . ";\n"
);
return true;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接自动回复语句(一行一条)
*/
class SplitAutoReplyService
{
/** 最多条数 */
private const MAX_LINES = 200;
/** 单条最大字符数 */
private const MAX_LINE_LENGTH = 500;
/** 列表预览最大字符数 */
private const LIST_PREVIEW_MAX = 20;
/**
* 列表单元格预览(单行最多 50 字,超出加 ...)
*/
public static function previewForList(string $raw, int $max = self::LIST_PREVIEW_MAX): string
{
$display = self::formatDisplay($raw);
if ($display === '') {
return '';
}
$flat = preg_replace('/\s+/u', ' ', str_replace(["\r\n", "\r", "\n"], ' ', $display));
$flat = trim((string) $flat);
if ($flat === '') {
return '';
}
if (mb_strlen($flat, 'UTF-8') <= $max) {
return $flat;
}
return mb_substr($flat, 0, $max, 'UTF-8') . '...';
}
/**
* 解析为多行文本数组
*
* @return array<int, string>
*/
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<int, string> $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);
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use think\Exception;
/**
* 分流链接码生成服务
*/
class SplitLinkCodeService
{
private const CODE_LENGTH = 9;
private const CHARSET = 'abcdefghijklmnopqrstuvwxyz';
private const MAX_ATTEMPTS = 50;
/**
* 规范化链接码(小写、去空格)
*/
public static function normalize(string $code): string
{
return strtolower(trim($code));
}
/**
* 是否为合法 9 位小写字母链接码
*/
public static function isValidFormat(string $code): bool
{
return (bool) preg_match('/^[a-z]{9}$/', $code);
}
/**
* 链接码是否未被占用
*/
public static function isAvailable(string $code, int $excludeId = 0): bool
{
$code = self::normalize($code);
$query = Link::where('link_code', $code);
if ($excludeId > 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;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\Domain as DomainModel;
/**
* 平台分配域名解析(支持多域名,一行一个)
*/
class SplitPlatformDomainService
{
/**
* 解析配置文本为合法根域名列表(去重、小写)
*
* @param string $raw 换行或逗号分隔的域名文本
* @return array<int, string>
*/
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<int, string> $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));
}
}
@@ -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('');
$('<style id="split-link-copy-modal-style" type="text/css"></style>').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 '<span class="text-muted">-</span>';
}
var preview = value != null && value !== '' ? String(value) : full;
var safePreview = Fast.api.escape(preview);
var encFull = encodeURIComponent(full);
return '<span class="split-auto-reply-preview" data-full="' + encFull + '" style="display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;cursor:default;">'
+ safePreview + '</span>';
},
/**
* 分流链接列:链接样式 + COPY 图标,点击链接打开弹窗
*/
linkCode: function (value) {
value = value == null ? '' : value.toString();
if (value === '') {
return '-';
}
var safe = Fast.api.escape(value);
return '<span class="split-link-code-cell">'
+ '<a href="javascript:;" class="btn-copy-split-link split-link-code-link" data-link-code="' + safe + '" style="font-weight:600;color:#337ab7;text-decoration:underline;">' + safe + '</a>'
+ ' <a href="javascript:;" class="btn-copy-link-code-inline text-primary" data-link-code="' + safe + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></a>'
+ '</span>';
}
},
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 = '<div style="white-space:pre-wrap;word-break:break-word;padding:10px 12px;line-height:1.6;max-width:420px;font-size:13px;text-align:left;">'
+ Controller.api.escapeHtmlWithNewlines(full) + '</div>';
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 $('<div/>').text(text).html().replace(/\n/g, '<br>');
},
/**
* 打开自动回复弹窗
*
* @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 = [
'<div class="split-auto-reply-modal" style="padding:16px 20px;">',
' <div class="form-group">',
' <label class="control-label">' + __('Link_code') + '</label>',
' <p class="form-control-static" style="font-size:16px;font-weight:600;color:#337ab7;margin:0;">' + linkCode + '</p>',
' </div>',
' <div class="form-group" style="margin-bottom:0;">',
' <label class="control-label" for="split-auto-reply-text">' + __('Reply statements') + '</label>',
' <p class="help-block" style="margin-top:0;">' + __('Reply statements tip') + '</p>',
' <textarea id="split-auto-reply-text" class="form-control" rows="10" placeholder="">' + Fast.api.escape(autoReply) + '</textarea>',
' </div>',
'</div>'
].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 = '<div class="split-domain-list">' + platformItems.join('') + '</div>';
} else {
platformHtml = '<p class="split-domain-empty">'
+ __('Split platform domain empty')
+ ' <a href="javascript:;" class="btn-goto-config">' + __('Go system config') + '</a>'
+ '</p>';
}
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 = '<div class="split-domain-list">' + items.join('') + '</div>';
} else {
myDomainsHtml = '<p class="split-domain-empty">' + __('Split my domain empty') + '</p>';
}
var initialUrl = Controller.api.buildSplitUrl(defaultDomain, linkCode);
var safeLinkCode = Fast.api.escape(linkCode);
var html = [
'<div class="split-link-copy-modal">',
' <div class="form-group">',
' <label class="control-label">' + __('Link_code') + '</label>',
' <div class="split-link-code-row">',
' <a href="javascript:;" class="split-link-code-value" data-link-code="' + safeLinkCode + '">' + safeLinkCode + '</a>',
' <button type="button" class="btn btn-default btn-sm btn-copy-link-code" data-link-code="' + safeLinkCode + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></button>',
' <button type="button" class="btn btn-default btn-sm btn-manage-domain">' + __('Manage my domains') + '</button>',
' </div>',
' </div>',
' <div class="form-group">',
' <label class="control-label">' + __('Select main domain') + '</label>',
' <ul class="nav nav-tabs split-domain-tabs" role="tablist">',
' <li role="presentation"' + (activeTab === 'platform' ? ' class="active"' : '') + '>',
' <a href="#split-tab-platform" role="tab" data-toggle="tab">' + __('Platform assigned domain') + '</a>',
' </li>',
' <li role="presentation"' + (activeTab === 'my' ? ' class="active"' : '') + '>',
' <a href="#split-tab-my" role="tab" data-toggle="tab">' + __('My domains') + '</a>',
' </li>',
' </ul>',
' <div class="tab-content split-domain-tab-box">',
' <div role="tabpanel" class="tab-pane' + (activeTab === 'platform' ? ' active' : '') + '" id="split-tab-platform">' + platformHtml + '</div>',
' <div role="tabpanel" class="tab-pane' + (activeTab === 'my' ? ' active' : '') + '" id="split-tab-my">' + myDomainsHtml + '</div>',
' </div>',
' </div>',
' <div class="split-tip-box">',
__('Split domain prefix tip'),
' <button type="button" class="btn btn-xs btn-primary btn-go-add-domain">' + __('Go add domain') + '</button>',
' </div>',
' <div class="form-group" style="margin-bottom:0;">',
' <label class="control-label">' + __('Generated result') + '</label>',
' <div class="input-group">',
' <input type="text" class="form-control split-generated-url" readonly value="' + Fast.api.escape(initialUrl) + '">',
' <span class="input-group-btn">',
' <button type="button" class="btn btn-default btn-copy-generated-url"><i class="fa fa-copy"></i> ' + __('Copy') + '</button>',
' </span>',
' </div>',
' </div>',
' <div class="text-center split-modal-footer">',
' <button type="button" class="btn btn-success btn-open-test"' + (initialUrl ? '' : ' disabled') + '>' + __('Open test') + '</button>',
' <button type="button" class="btn btn-default btn-close-copy-modal">' + __('Close') + '</button>',
' </div>',
'</div>'
].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 = '<span class="split-domain-status">'
+ '<span class="label ' + Controller.api.statusLabelClass(options.nsStatus) + '">NS:' + Fast.api.escape(options.nsText || '-') + '</span>'
+ '<span class="label ' + Controller.api.statusLabelClass(options.dnsStatus, true) + '">DNS:' + Fast.api.escape(options.dnsText || '-') + '</span>'
+ '</span>';
}
return '<div class="split-domain-item' + checkedClass + '">'
+ '<label class="split-domain-item-label">'
+ '<input type="radio" name="split_main_domain" value="' + domain + '" data-type="' + type + '"' + checked + '>'
+ '<span class="split-domain-item-body">'
+ '<span class="split-domain-name">' + domain + '</span>'
+ statusHtml
+ '</span>'
+ '</label>'
+ '</div>';
},
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 = $('<textarea>').css({position: 'fixed', left: '-9999px', top: '0'}).val(text);
$('body').append($temp);
$temp[0].select();
try {
document.execCommand('copy');
Toastr.success(__('Copy success'));
} catch (e) {
Toastr.error(__('Copy failed'));
}
$temp.remove();
},
bindLinkCodeInput: function () {
var $input = $('#c-link_code');
if (!$input.length || $input.prop('readonly')) {
return;
}
$input.on('input', function () {
var val = $(this).val().replace(/[^a-zA-Z]/g, '').toLowerCase().slice(0, 9);
if ($(this).val() !== val) {
$(this).val(val);
}
});
},
bindevent: function () {
Form.api.bindevent($("form[role=form]"));
}
}
};
return Controller;
});
View File
+515
View File
@@ -0,0 +1,515 @@
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,
class: 'autocontent',
hover: true,
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);
});
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('');
$('<style id="split-link-copy-modal-style" type="text/css"></style>').text(css).appendTo('head');
},
formatter: {
/**
* 回复语:单元格内省略,悬停显示完整内容
*/
autoReplyText: function (value, row, index) {
value = value == null ? '' : value.toString();
if (value === '') {
return '<span class="text-muted">-</span>';
}
var safe = Fast.api.escape(value);
return "<div class='autocontent-item autocontent-hover' style='white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:100%;display:block;' title='"
+ safe + "'>" + safe + "</div>";
},
/**
* 分流链接列:链接样式 + COPY 图标,点击链接打开弹窗
*/
linkCode: function (value) {
value = value == null ? '' : value.toString();
if (value === '') {
return '-';
}
var safe = Fast.api.escape(value);
return '<span class="split-link-code-cell">'
+ '<a href="javascript:;" class="btn-copy-split-link split-link-code-link" data-link-code="' + safe + '" style="font-weight:600;color:#337ab7;text-decoration:underline;">' + safe + '</a>'
+ ' <a href="javascript:;" class="btn-copy-link-code-inline text-primary" data-link-code="' + safe + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></a>'
+ '</span>';
}
},
openCopyModal: function (linkCode) {
Controller.api.loadCopyModalData(function (data) {
Controller.api.renderCopyModal(linkCode, data);
});
},
/**
* 打开自动回复弹窗
*
* @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 = [
'<div class="split-auto-reply-modal" style="padding:16px 20px;">',
' <div class="form-group">',
' <label class="control-label">' + __('Link_code') + '</label>',
' <p class="form-control-static" style="font-size:16px;font-weight:600;color:#337ab7;margin:0;">' + linkCode + '</p>',
' </div>',
' <div class="form-group" style="margin-bottom:0;">',
' <label class="control-label" for="split-auto-reply-text">' + __('Reply statements') + '</label>',
' <p class="help-block" style="margin-top:0;">' + __('Reply statements tip') + '</p>',
' <textarea id="split-auto-reply-text" class="form-control" rows="10" placeholder="">' + Fast.api.escape(autoReply) + '</textarea>',
' </div>',
'</div>'
].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 = '<div class="split-domain-list">' + platformItems.join('') + '</div>';
} else {
platformHtml = '<p class="split-domain-empty">'
+ __('Split platform domain empty')
+ ' <a href="javascript:;" class="btn-goto-config">' + __('Go system config') + '</a>'
+ '</p>';
}
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 = '<div class="split-domain-list">' + items.join('') + '</div>';
} else {
myDomainsHtml = '<p class="split-domain-empty">' + __('Split my domain empty') + '</p>';
}
var initialUrl = Controller.api.buildSplitUrl(defaultDomain, linkCode);
var safeLinkCode = Fast.api.escape(linkCode);
var html = [
'<div class="split-link-copy-modal">',
' <div class="form-group">',
' <label class="control-label">' + __('Link_code') + '</label>',
' <div class="split-link-code-row">',
' <a href="javascript:;" class="split-link-code-value" data-link-code="' + safeLinkCode + '">' + safeLinkCode + '</a>',
' <button type="button" class="btn btn-default btn-sm btn-copy-link-code" data-link-code="' + safeLinkCode + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></button>',
' <button type="button" class="btn btn-default btn-sm btn-manage-domain">' + __('Manage my domains') + '</button>',
' </div>',
' </div>',
' <div class="form-group">',
' <label class="control-label">' + __('Select main domain') + '</label>',
' <ul class="nav nav-tabs split-domain-tabs" role="tablist">',
' <li role="presentation"' + (activeTab === 'platform' ? ' class="active"' : '') + '>',
' <a href="#split-tab-platform" role="tab" data-toggle="tab">' + __('Platform assigned domain') + '</a>',
' </li>',
' <li role="presentation"' + (activeTab === 'my' ? ' class="active"' : '') + '>',
' <a href="#split-tab-my" role="tab" data-toggle="tab">' + __('My domains') + '</a>',
' </li>',
' </ul>',
' <div class="tab-content split-domain-tab-box">',
' <div role="tabpanel" class="tab-pane' + (activeTab === 'platform' ? ' active' : '') + '" id="split-tab-platform">' + platformHtml + '</div>',
' <div role="tabpanel" class="tab-pane' + (activeTab === 'my' ? ' active' : '') + '" id="split-tab-my">' + myDomainsHtml + '</div>',
' </div>',
' </div>',
' <div class="split-tip-box">',
__('Split domain prefix tip'),
' <button type="button" class="btn btn-xs btn-primary btn-go-add-domain">' + __('Go add domain') + '</button>',
' </div>',
' <div class="form-group" style="margin-bottom:0;">',
' <label class="control-label">' + __('Generated result') + '</label>',
' <div class="input-group">',
' <input type="text" class="form-control split-generated-url" readonly value="' + Fast.api.escape(initialUrl) + '">',
' <span class="input-group-btn">',
' <button type="button" class="btn btn-default btn-copy-generated-url"><i class="fa fa-copy"></i> ' + __('Copy') + '</button>',
' </span>',
' </div>',
' </div>',
' <div class="text-center split-modal-footer">',
' <button type="button" class="btn btn-success btn-open-test"' + (initialUrl ? '' : ' disabled') + '>' + __('Open test') + '</button>',
' <button type="button" class="btn btn-default btn-close-copy-modal">' + __('Close') + '</button>',
' </div>',
'</div>'
].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 = '<span class="split-domain-status">'
+ '<span class="label ' + Controller.api.statusLabelClass(options.nsStatus) + '">NS:' + Fast.api.escape(options.nsText || '-') + '</span>'
+ '<span class="label ' + Controller.api.statusLabelClass(options.dnsStatus, true) + '">DNS:' + Fast.api.escape(options.dnsText || '-') + '</span>'
+ '</span>';
}
return '<div class="split-domain-item' + checkedClass + '">'
+ '<label class="split-domain-item-label">'
+ '<input type="radio" name="split_main_domain" value="' + domain + '" data-type="' + type + '"' + checked + '>'
+ '<span class="split-domain-item-body">'
+ '<span class="split-domain-name">' + domain + '</span>'
+ statusHtml
+ '</span>'
+ '</label>'
+ '</div>';
},
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 = $('<textarea>').css({position: 'fixed', left: '-9999px', top: '0'}).val(text);
$('body').append($temp);
$temp[0].select();
try {
document.execCommand('copy');
Toastr.success(__('Copy success'));
} catch (e) {
Toastr.error(__('Copy failed'));
}
$temp.remove();
},
bindLinkCodeInput: function () {
var $input = $('#c-link_code');
if (!$input.length || $input.prop('readonly')) {
return;
}
$input.on('input', function () {
var val = $(this).val().replace(/[^a-zA-Z]/g, '').toLowerCase().slice(0, 9);
if ($(this).val() !== val) {
$(this).val(val);
}
});
},
bindevent: function () {
Form.api.bindevent($("form[role=form]"));
}
}
};
return Controller;
});
View File