链接管理模块
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>
|
||||
Reference in New Issue
Block a user