链接管理模块
This commit is contained in:
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出后台列表/表单 JS(patches 未部署到 public 时由 RequireJS 加载)
|
||||
*/
|
||||
public function script(): void
|
||||
{
|
||||
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js';
|
||||
if (!is_file($jsFile)) {
|
||||
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/link.js';
|
||||
}
|
||||
if (!is_file($jsFile)) {
|
||||
$this->error('脚本文件不存在');
|
||||
}
|
||||
$content = file_get_contents($jsFile);
|
||||
if ($content === false) {
|
||||
$this->error('读取脚本失败');
|
||||
}
|
||||
$response = response($content, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
throw new \think\exception\HttpResponseException($response);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user