feat(cloudflare): 添加TXT记录目标域名选择功能

- 新增hostnames_txt_targets接口用于查找TXT记录的目标域名候选列表
- 实现findTxtRecordTargetDomains方法用于匹配最合适的解析域名
- 添加matchHostnameToDomainRecordName方法用于主机名与域名匹配逻辑
- 在前端页面中集成TXT记录快速添加的域名选择弹窗功能
- 更新域名快速切换功能,增加onclick事件处理
- 升级静态资源版本号以确保缓存更新
```
This commit is contained in:
luo-bo
2026-04-02 03:33:10 +08:00
parent a9b773868d
commit 7670d5a387
5 changed files with 306 additions and 76 deletions

View File

@@ -3,6 +3,7 @@
namespace app\controller;
use app\BaseController;
use app\lib\DnsHelper;
use app\service\CloudflareEnhanceService;
use Exception;
use think\facade\Db;
@@ -143,6 +144,27 @@ class Cloudflare extends BaseController
}
}
public function hostnames_txt_targets()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostname = trim(input('post.hostname', '', 'trim'));
if ($hostname === '') {
throw new Exception('缺少 TXT 主机名');
}
return json([
'code' => 0,
'data' => [
'hostname' => $hostname,
'candidates' => $this->findTxtRecordTargetDomains($context['domain'], $hostname),
],
]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'data' => ['candidates' => []]]);
}
}
public function fallback_get()
{
try {
@@ -802,6 +824,85 @@ class Cloudflare extends BaseController
];
}
private function findTxtRecordTargetDomains(array $currentDomain, string $hostname): array
{
$rows = Db::name('domain')->alias('D')
->join('account A', 'D.aid = A.id')
->field('D.id,D.aid,D.name,A.type account_type,A.name account_name,A.remark account_remark')
->select()
->toArray();
$candidates = [];
$bestLength = -1;
foreach ($rows as $row) {
$recordName = $this->matchHostnameToDomainRecordName($hostname, $row['name'] ?? '');
if ($recordName === null) {
continue;
}
$domainName = $this->normalizeHostname($row['name'] ?? '');
$matchedLength = strlen($domainName);
if ($matchedLength > $bestLength) {
$bestLength = $matchedLength;
$candidates = [];
}
if ($matchedLength === $bestLength) {
$candidates[] = $this->formatTxtTargetCandidate($row, $recordName, intval($currentDomain['id'] ?? 0));
}
}
if (empty($candidates)) {
$fallbackRecordName = $this->matchHostnameToDomainRecordName($hostname, $currentDomain['name'] ?? '', true);
if ($fallbackRecordName !== null) {
$candidates[] = $this->formatTxtTargetCandidate([
'id' => $currentDomain['id'] ?? 0,
'aid' => $currentDomain['aid'] ?? 0,
'name' => $currentDomain['name'] ?? '',
'account_type' => $currentDomain['type'] ?? '',
'account_name' => $currentDomain['account_name'] ?? '',
'account_remark' => $currentDomain['account_remark'] ?? '',
], $fallbackRecordName, intval($currentDomain['id'] ?? 0));
}
}
usort($candidates, function ($a, $b) {
if ($a['is_current_domain'] !== $b['is_current_domain']) {
return $a['is_current_domain'] ? -1 : 1;
}
$providerCompare = strcmp($a['account_type_name'], $b['account_type_name']);
if ($providerCompare !== 0) {
return $providerCompare;
}
$accountCompare = strcmp($a['account_display_name'], $b['account_display_name']);
if ($accountCompare !== 0) {
return $accountCompare;
}
return strcmp($a['domain_name'], $b['domain_name']);
});
return $candidates;
}
private function formatTxtTargetCandidate(array $row, string $recordName, int $currentDomainId): array
{
$account = [
'id' => intval($row['aid'] ?? 0),
'name' => trim((string)($row['account_name'] ?? '')),
'remark' => trim((string)($row['account_remark'] ?? '')),
];
$accountType = trim((string)($row['account_type'] ?? ''));
return [
'domain_id' => intval($row['id'] ?? 0),
'domain_name' => trim((string)($row['name'] ?? '')),
'record_name' => $recordName,
'account_id' => $account['id'],
'account_type' => $accountType,
'account_type_name' => $this->formatDnsTypeName($accountType),
'account_display_name' => $this->formatAccountDisplayName($account),
'is_current_domain' => intval($row['id'] ?? 0) === $currentDomainId,
];
}
private function formatAccountDisplayName(array $account): string
{
$name = trim((string)($account['name'] ?? ''));
@@ -896,16 +997,44 @@ class Cloudflare extends BaseController
if ($domainName === '') {
continue;
}
if ($hostname === $domainName || str_ends_with($hostname, '.' . $domainName)) {
if (strlen($domainName) > $bestLength) {
$best = $domain;
$bestLength = strlen($domainName);
}
if ($this->matchHostnameToDomainRecordName($hostname, $domainName) !== null && strlen($domainName) > $bestLength) {
$best = $domain;
$bestLength = strlen($domainName);
}
}
return $best;
}
private function matchHostnameToDomainRecordName(string $hostname, string $domainName, bool $allowRelative = false): ?string
{
$hostname = preg_replace('/^\*\./', '', $this->normalizeHostname($hostname));
$domainName = $this->normalizeHostname($domainName);
if ($hostname === '' || $domainName === '') {
return null;
}
if ($hostname === $domainName) {
return '@';
}
if (str_ends_with($hostname, '.' . $domainName)) {
return substr($hostname, 0, -strlen($domainName) - 1);
}
if ($allowRelative) {
if ($hostname === '@') {
return '@';
}
if (!str_contains($hostname, '.')) {
return $hostname;
}
}
return null;
}
private function formatDnsTypeName(string $type): string
{
$dnsList = DnsHelper::getList();
return $dnsList[$type]['name'] ?? ($type !== '' ? $type : '-');
}
private function normalizeHostname($hostname): string
{
$hostname = trim((string)$hostname);

View File

@@ -14,7 +14,7 @@
{/volist}
</select>
</div>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;"><i class="fa fa-random fa-fw"></i> 切换域名</button>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;" onclick="return quickSwitchDomain('/cloudflare/hostnames/')"><i class="fa fa-random fa-fw"></i> 切换域名</button>
<a href="/record/{$domainId}" class="btn btn-sm btn-default" style="vertical-align:middle;"><i class="fa fa-reply fa-fw"></i> 返回解析</a>
</div>
<h3 class="panel-title" style="padding-top:4px;">Cloudflare增强 - {$domainName}</h3>
@@ -109,16 +109,16 @@
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js"></script>
<script src="/static/js/custom.js?v=1005"></script>
<script>
var currentVerificationHostnameId = '';
var currentDomainName = '{$domainName}';
$(document).ready(function(){
initDomainQuickSwitch({
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
type: 'cloudflare',
initDomainQuickSwitch({
buttonSelector: '',
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
type: 'cloudflare',
buildUrl: function(id){
return '/cloudflare/hostnames/' + id;
}
@@ -452,65 +452,145 @@ function fallbackCopyText(text){
function quickAddTxtRecord(btn){
var fullName = decodeURIComponent($(btn).attr('data-name') || '');
var value = decodeURIComponent($(btn).attr('data-value') || '');
var rr = convertFullHostnameToRecordName(fullName);
if(rr === null){
layer.alert('TXT 记录名称与当前域名不匹配,无法自动添加,请手动到解析页添加', {icon: 2});
return;
}
layer.confirm('确定要快速添加 TXT 记录吗?<br><code>' + htmlEscape(fullName) + '</code>', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/record/add/{$domainId}',
data: {
name: rr,
type: 'TXT',
value: value,
line: '0',
ttl: 600,
mx: 1,
weight: 0,
remark: 'Cloudflare证书校验'
},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-verification").modal('show');
layer.msg('TXT 记录添加成功', {icon: 1, time: 1200});
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
resolveTxtRecordTargets(fullName, function(targets){
if(!targets.length){
layer.alert('系统中未找到与该 TXT 主机名对应的托管域名,请手动到解析页添加', {icon: 2});
return;
}
if(targets.length === 1){
confirmQuickAddTxtRecord(fullName, value, targets[0]);
return;
}
openTxtTargetPicker(fullName, value, targets);
});
}
function convertFullHostnameToRecordName(fullName){
var name = String(fullName || '').trim().replace(/\.$/, '');
var domain = String(currentDomainName || '').trim().replace(/\.$/, '');
if(!name || !domain){
return null;
function resolveTxtRecordTargets(fullName, callback){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/hostnames/txttargets/{$domainId}',
data: {hostname: fullName},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
var targets = res.data && $.isArray(res.data.candidates) ? res.data.candidates : [];
callback(targets);
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function openTxtTargetPicker(fullName, value, targets){
var html = '<div style="padding:16px 18px 6px;">';
html += '<div class="alert alert-warning" style="margin-bottom:12px;">检测到多个可用解析域名,请确认要写入哪个服务商。</div>';
html += '<div class="form-group"><label>TXT 主机名</label><div><code>' + htmlEscape(fullName) + '</code></div></div>';
html += '<div class="form-group"><label>TXT 值</label><textarea class="form-control" rows="3" readonly>' + htmlEscape(value) + '</textarea></div>';
html += '<form id="txtTargetPickerForm">';
for(var i = 0; i < targets.length; i++){
var target = targets[i] || {};
var providerName = target.account_type_name || target.account_type || '-';
var accountName = target.account_display_name || ('账户#' + (target.account_id || ''));
html += '<div class="radio" style="margin:0 0 12px;border:1px solid #e5e5e5;border-radius:4px;padding:10px 12px;">';
html += '<label style="display:block;padding-left:22px;">';
html += '<input type="radio" name="txtTarget" value="' + htmlEscape(String(target.domain_id || '')) + '"' + (i === 0 ? ' checked' : '') + '>';
html += '<strong>' + htmlEscape(target.domain_name || '-') + '</strong>';
if(target.is_current_domain){
html += ' <span class="label label-primary">当前页</span>';
}
html += '<div class="help-block" style="margin:8px 0 0;">';
html += '主机记录:<code>' + htmlEscape(target.record_name || '@') + '</code><br>';
html += '服务商:' + htmlEscape(providerName) + '<br>';
html += '账户:' + htmlEscape(accountName);
html += '</div>';
html += '</label></div>';
}
var lowerName = name.toLowerCase();
var lowerDomain = domain.toLowerCase();
if(lowerName === lowerDomain){
return '@';
}
if(lowerName.endsWith('.' + lowerDomain)){
return name.slice(0, name.length - domain.length - 1);
}
if(name === '@'){
return '@';
}
if(name.indexOf('.') === -1){
return name;
html += '</form></div>';
layer.open({
type: 1,
title: '选择解析服务商',
area: ['640px', 'auto'],
shadeClose: false,
content: html,
btn: ['添加 TXT', '取消'],
yes: function(index){
var selectedId = $('#txtTargetPickerForm input[name=txtTarget]:checked').val();
var target = findTxtTargetByDomainId(targets, selectedId);
if(!target){
layer.msg('请选择要写入的解析域名', {icon: 2});
return;
}
layer.close(index);
submitQuickAddTxtRecord(value, target);
}
});
}
function confirmQuickAddTxtRecord(fullName, value, target){
layer.confirm(buildQuickAddConfirmHtml(fullName, target), {title: '提示', icon: 0}, function(index){
layer.close(index);
submitQuickAddTxtRecord(value, target);
});
}
function buildQuickAddConfirmHtml(fullName, target){
var providerName = target.account_type_name || target.account_type || '-';
var accountName = target.account_display_name || ('账户#' + (target.account_id || ''));
return '确定要快速添加 TXT 记录吗?<br><br>'
+ 'TXT 主机名:<code>' + htmlEscape(fullName) + '</code><br>'
+ '解析域名:<code>' + htmlEscape(target.domain_name || '-') + '</code><br>'
+ '主机记录:<code>' + htmlEscape(target.record_name || '@') + '</code><br>'
+ '服务商:' + htmlEscape(providerName) + '<br>'
+ '账户:' + htmlEscape(accountName);
}
function submitQuickAddTxtRecord(value, target){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/record/add/' + target.domain_id,
data: {
name: target.record_name,
type: 'TXT',
value: value,
line: '0',
ttl: 600,
mx: 1,
weight: 0,
remark: 'Cloudflare证书校验'
},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-verification").modal('show');
layer.msg('TXT 记录已添加到 ' + (target.domain_name || '-'), {icon: 1, time: 1400});
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function findTxtTargetByDomainId(targets, domainId){
var selected = String(domainId || '');
for(var i = 0; i < targets.length; i++){
var item = targets[i] || {};
if(String(item.domain_id || '') === selected){
return item;
}
}
return null;
}

View File

@@ -176,7 +176,7 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
{/volist}
</select>
</div>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;"><i class="fa fa-random fa-fw"></i> 切换域名</button>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;" onclick="return quickSwitchDomain('/record/')"><i class="fa fa-random fa-fw"></i> 切换域名</button>
{if $user['type'] eq 'user'}<a href="/domain" class="btn btn-sm btn-default" style="vertical-align:middle;"><i class="fa fa-reply fa-fw"></i> 返回</a>{/if}
</div>
<h3 class="panel-title" style="padding-top:4px;">{$domainName}</h3>
@@ -256,7 +256,7 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js?v=1003"></script>
<script src="/static/js/custom.js?v=1005"></script>
<script>
var recordLine = {$recordLine|json_encode|raw};
var dnsconfig = {$dnsconfig|json_encode|raw};
@@ -264,13 +264,16 @@ var defaultLine = recordLine[0].id;
var sidePagination = dnsconfig.page ? 'client' : 'server';
var showWeight = dnsconfig.weight;
$(document).ready(function(){
initDomainQuickSwitch({
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
buildUrl: function(id){
return '/record/' + id;
}
});
if(typeof initDomainQuickSwitch === 'function'){
initDomainQuickSwitch({
buttonSelector: '',
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
buildUrl: function(id){
return '/record/' + id;
}
});
}
updateToolbar();
let defaultPageSize = getCookie('record_pagesize') ? getCookie('record_pagesize') : 15;
const pageNumber = typeof window.$_GET['pageNumber'] != 'undefined' ? parseInt(window.$_GET['pageNumber']) : 1;

View File

@@ -143,6 +143,23 @@ function initDomainQuickSwitch(options){
};
}
function quickSwitchDomain(basePath){
if(typeof $ === 'undefined'){
return false;
}
var targetId = $.trim($('#quickDomainSwitch').val());
if(targetId === ''){
if(typeof layer !== 'undefined'){
layer.msg('请先选择域名');
}else{
alert('请先选择域名');
}
return false;
}
window.location.href = String(basePath || '') + targetId;
return false;
}
if (typeof $.fn.bootstrapTable !== "undefined") {
$.fn.bootstrapTable.custom = {
method: 'post',

View File

@@ -57,6 +57,7 @@ Route::group(function () {
Route::post('/cloudflare/hostnames/update/:id', 'cloudflare/hostnames_update');
Route::post('/cloudflare/hostnames/refresh/:id', 'cloudflare/hostnames_refresh');
Route::post('/cloudflare/hostnames/delete/:id', 'cloudflare/hostnames_delete');
Route::post('/cloudflare/hostnames/txttargets/:id', 'cloudflare/hostnames_txt_targets');
Route::post('/cloudflare/fallback/get/:id', 'cloudflare/fallback_get');
Route::post('/cloudflare/fallback/set/:id', 'cloudflare/fallback_set');
Route::post('/cloudflare/fallback/delete/:id', 'cloudflare/fallback_delete');