This commit is contained in:
parent
71f28990e1
commit
897c6cc8e8
@ -196,7 +196,11 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if reqData["step"] == "dns" && reqData["action"] == "get" {
|
if reqData["step"] == "dns" && reqData["action"] == "get" {
|
||||||
dnsInfos, err := setup.GetDNSSettings(ctx)
|
dnsInfos, err := setup.GetDNSSettingsWithIPv4(ctx, reqData["mail_ipv4"])
|
||||||
|
if err == setup.ErrInvalidDNSIPv4 {
|
||||||
|
response.NewErrorResponse(response.ParamsError, err.Error(), "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSetupError(ctx, w, err)
|
writeSetupError(ctx, w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package setup
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
stdcontext "context"
|
stdcontext "context"
|
||||||
|
stderrors "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@ -17,18 +18,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DNSItem struct {
|
type DNSItem struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
TTL int `json:"ttl"`
|
TTL int `json:"ttl"`
|
||||||
Tips string `json:"tips"`
|
Tips string `json:"tips"`
|
||||||
|
UnavailableReason string `json:"unavailableReason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const dnsRecordTTL = 600
|
const dnsRecordTTL = 600
|
||||||
|
|
||||||
const unresolvedWebIPv4 = "[未解析到 Web 域名公网 IPv4]"
|
const unresolvedWebIPv4 = "[未解析到 Web 域名公网 IPv4]"
|
||||||
|
|
||||||
|
var ErrInvalidDNSIPv4 = stderrors.New("请输入有效的邮件服务器公网 IPv4 地址,不支持内网地址、域名或端口")
|
||||||
|
|
||||||
func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
|
func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
|
||||||
|
return GetDNSSettingsWithIPv4(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手工地址只用于本次生成 DNS 建议,不写入配置,也不替代 Web 域名解析。
|
||||||
|
func GetDNSSettingsWithIPv4(ctx *context.Context, manualIPv4 string) (map[string][]*DNSItem, error) {
|
||||||
|
ipv4 := normalizeDNSAddress(manualIPv4)
|
||||||
|
if strings.TrimSpace(manualIPv4) != "" && ipv4 == "" {
|
||||||
|
return nil, ErrInvalidDNSIPv4
|
||||||
|
}
|
||||||
configData, err := config.ReadConfig()
|
configData, err := config.ReadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err)
|
return nil, errors.Wrap(err)
|
||||||
@ -39,7 +52,9 @@ func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
|
|||||||
return nil, errors.Wrap(err)
|
return nil, errors.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv4 := ip.GetIPv4()
|
if ipv4 == "" {
|
||||||
|
ipv4 = ip.GetIPv4()
|
||||||
|
}
|
||||||
ipv6 := ip.GetIPv6()
|
ipv6 := ip.GetIPv6()
|
||||||
webIPv4 := resolvePublicIPv4(configData.WebDomain)
|
webIPv4 := resolvePublicIPv4(configData.WebDomain)
|
||||||
ipTips := i18n.GetText(ctx.Lang, "ip_taps")
|
ipTips := i18n.GetText(ctx.Lang, "ip_taps")
|
||||||
@ -57,8 +72,9 @@ func buildDNSItems(configData *config.Config, domain, dkimPublicKey, ipv4, ipv6,
|
|||||||
|
|
||||||
func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, ipv4, ipv6, ipTips string, webIPv4 []string) []*DNSItem {
|
func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, ipv4, ipv6, ipTips string, webIPv4 []string) []*DNSItem {
|
||||||
address := normalizeDNSAddress(ipv4)
|
address := normalizeDNSAddress(ipv4)
|
||||||
|
unavailableReason := ""
|
||||||
if address == "" {
|
if address == "" {
|
||||||
address = "[未检测到公网 IPv4]"
|
unavailableReason = "未检测到公网 IPv4,请重新探测或手工填写"
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]*DNSItem, 0, 14)
|
items := make([]*DNSItem, 0, 14)
|
||||||
@ -69,11 +85,12 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = append(items, &DNSItem{
|
items = append(items, &DNSItem{
|
||||||
Type: "A",
|
Type: "A",
|
||||||
Host: host,
|
Host: host,
|
||||||
Value: address,
|
Value: address,
|
||||||
TTL: dnsRecordTTL,
|
TTL: dnsRecordTTL,
|
||||||
Tips: ipTips,
|
Tips: ipTips,
|
||||||
|
UnavailableReason: unavailableReason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendAAAARecord := func(host, address string) {
|
appendAAAARecord := func(host, address string) {
|
||||||
@ -106,11 +123,12 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey,
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items = append(items, &DNSItem{
|
items = append(items, &DNSItem{
|
||||||
Type: "A",
|
Type: "A",
|
||||||
Host: webHost,
|
Host: webHost,
|
||||||
Value: unresolvedWebIPv4,
|
Value: "",
|
||||||
TTL: dnsRecordTTL,
|
TTL: dnsRecordTTL,
|
||||||
Tips: "Web 域名未解析到公网 IPv4,请确认域名解析状态",
|
Tips: "Web 域名未解析到公网 IPv4,请确认域名解析状态",
|
||||||
|
UnavailableReason: unresolvedWebIPv4,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendARecord("smtp")
|
appendARecord("smtp")
|
||||||
@ -126,9 +144,13 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey,
|
|||||||
}
|
}
|
||||||
appendARecord("*")
|
appendARecord("*")
|
||||||
|
|
||||||
|
spf := &DNSItem{Type: "TXT", Host: "@", Value: buildSPFRecord(ipv4, ipv6), TTL: dnsRecordTTL}
|
||||||
|
if spf.Value == "" {
|
||||||
|
spf.UnavailableReason = "缺少公网 IP,暂不能生成 SPF"
|
||||||
|
}
|
||||||
items = append(items,
|
items = append(items,
|
||||||
&DNSItem{Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s | 1", domain), TTL: dnsRecordTTL},
|
&DNSItem{Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s | 1", domain), TTL: dnsRecordTTL},
|
||||||
&DNSItem{Type: "TXT", Host: "@", Value: buildSPFRecord(ipv4, ipv6), TTL: dnsRecordTTL},
|
spf,
|
||||||
&DNSItem{Type: "TXT", Host: "_dmarc", Value: buildDMARCRecord(domain), TTL: dnsRecordTTL},
|
&DNSItem{Type: "TXT", Host: "_dmarc", Value: buildDMARCRecord(domain), TTL: dnsRecordTTL},
|
||||||
&DNSItem{Type: "TXT", Host: "default._domainkey", Value: dkimPublicKey, TTL: dnsRecordTTL},
|
&DNSItem{Type: "TXT", Host: "default._domainkey", Value: dkimPublicKey, TTL: dnsRecordTTL},
|
||||||
)
|
)
|
||||||
@ -143,6 +165,9 @@ func buildSPFRecord(ipv4, ipv6 string) string {
|
|||||||
if mechanism := buildSPFAddressMechanism(ipv6); mechanism != "" {
|
if mechanism := buildSPFAddressMechanism(ipv6); mechanism != "" {
|
||||||
mechanisms = append(mechanisms, mechanism)
|
mechanisms = append(mechanisms, mechanism)
|
||||||
}
|
}
|
||||||
|
if len(mechanisms) == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
mechanisms = append(mechanisms, "~all")
|
mechanisms = append(mechanisms, "~all")
|
||||||
return strings.Join(mechanisms, " ")
|
return strings.Join(mechanisms, " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,9 @@ func TestBuildSPFRecordSkipsUnsafeAddresses(t *testing.T) {
|
|||||||
ipv6 string
|
ipv6 string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "empty", want: "v=spf1 ~all"},
|
{name: "empty", want: ""},
|
||||||
{name: "private IPv4", ipv4: "192.168.1.20", want: "v=spf1 ~all"},
|
{name: "private IPv4", ipv4: "192.168.1.20", want: ""},
|
||||||
{name: "link local IPv6", ipv6: "fe80::1", want: "v=spf1 ~all"},
|
{name: "link local IPv6", ipv6: "fe80::1", want: ""},
|
||||||
{name: "only IPv4", ipv4: "198.51.100.9", want: "v=spf1 ip4:198.51.100.9 ~all"},
|
{name: "only IPv4", ipv4: "198.51.100.9", want: "v=spf1 ip4:198.51.100.9 ~all"},
|
||||||
{name: "only IPv6", ipv6: "2001:db8::9", want: "v=spf1 ip6:2001:db8::9 ~all"},
|
{name: "only IPv6", ipv6: "2001:db8::9", want: "v=spf1 ip6:2001:db8::9 ~all"},
|
||||||
}
|
}
|
||||||
@ -101,7 +101,7 @@ func TestBuildDNSItemsDoesNotReuseMailIPForUnresolvedWebDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
items := buildDNSItemsWithWebIPv4(cfg, "example.net", "v=DKIM1; k=rsa; p=test", "198.51.100.30", "", "", nil)
|
items := buildDNSItemsWithWebIPv4(cfg, "example.net", "v=DKIM1; k=rsa; p=test", "198.51.100.30", "", "", nil)
|
||||||
|
|
||||||
if items[0].Host != "panel" || items[0].Value != unresolvedWebIPv4 {
|
if items[0].Host != "panel" || items[0].Value != "" || items[0].UnavailableReason != unresolvedWebIPv4 {
|
||||||
t.Fatalf("unresolved web A record = %#v", items[0])
|
t.Fatalf("unresolved web A record = %#v", items[0])
|
||||||
}
|
}
|
||||||
if items[1].Host != "smtp" || items[1].Value != "198.51.100.30" {
|
if items[1].Host != "smtp" || items[1].Value != "198.51.100.30" {
|
||||||
@ -147,3 +147,26 @@ func TestDNSHostForDomain(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDNSItemsWithoutPublicIPMarksIncompleteRecords(t *testing.T) {
|
||||||
|
cfg := &config.Config{Domain: "example.com", WebDomain: "panel.example.com"}
|
||||||
|
items := buildDNSItemsWithWebIPv4(cfg, "example.com", "v=DKIM1; k=rsa; p=test", "", "", "", []string{"203.0.113.20"})
|
||||||
|
for _, item := range items {
|
||||||
|
unavailable := (item.Type == "A" && item.Host != "panel") || (item.Type == "TXT" && item.Host == "@")
|
||||||
|
if unavailable {
|
||||||
|
if item.Value != "" || item.UnavailableReason == "" {
|
||||||
|
t.Fatalf("未就绪记录必须留空并提供原因:%#v", item)
|
||||||
|
}
|
||||||
|
} else if item.Value == "" || item.UnavailableReason != "" {
|
||||||
|
t.Fatalf("有效记录不应被禁用:%#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDNSSettingsRejectsInvalidManualIPv4BeforeLoadingConfig(t *testing.T) {
|
||||||
|
for _, value := range []string{"127.0.0.1", "192.168.1.1", "::1", "example.com", "203.0.113.20:25", "invalid"} {
|
||||||
|
if _, err := GetDNSSettingsWithIPv4(nil, value); err != ErrInvalidDNSIPv4 {
|
||||||
|
t.Fatalf("无效手工地址 %q 应返回参数错误,实际为 %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -333,6 +333,48 @@
|
|||||||
animation: pm-rise-in 0.46s var(--pm-ease-out);
|
animation: pm-rise-in 0.46s var(--pm-ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dns-address-controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-form .el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-input {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-note,
|
||||||
|
.dns-address-warning {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-note {
|
||||||
|
color: var(--pm-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-address-warning,
|
||||||
|
.dns-value.dns-value-unavailable {
|
||||||
|
color: var(--el-color-warning-dark-2, #b45309);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.dns-header {
|
.dns-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@ -119,6 +119,14 @@ export function usePage() {
|
|||||||
const dnsChallengeNotice = ref("")
|
const dnsChallengeNotice = ref("")
|
||||||
|
|
||||||
const dnsInfos = ref({})
|
const dnsInfos = ref({})
|
||||||
|
const dnsMailIPv4 = ref('')
|
||||||
|
const dnsAppliedIPv4 = ref('')
|
||||||
|
const dnsLoading = ref(false)
|
||||||
|
const dnsError = ref('')
|
||||||
|
const dnsInputPending = computed(() => dnsMailIPv4.value.trim() !== dnsAppliedIPv4.value)
|
||||||
|
const hasUnavailableDNS = computed(() => Object.values(dnsInfos.value).some((records) =>
|
||||||
|
records.some((record) => record.unavailableReason || !record.value)
|
||||||
|
))
|
||||||
|
|
||||||
const hasDNSIPv6 = computed(() => Object.values(dnsInfos.value).some((records) =>
|
const hasDNSIPv6 = computed(() => Object.values(dnsInfos.value).some((records) =>
|
||||||
records.some((record) => record.type === 'AAAA')
|
records.some((record) => record.type === 'AAAA')
|
||||||
@ -304,15 +312,34 @@ export function usePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDNSConfig = () => {
|
const getDNSConfig = async () => {
|
||||||
return setupRequest({"action": "get", "step": "dns"}).then((res) => {
|
if (dnsLoading.value) return null
|
||||||
|
dnsLoading.value = true
|
||||||
|
dnsError.value = ''
|
||||||
|
const mailIPv4 = dnsMailIPv4.value.trim()
|
||||||
|
try {
|
||||||
|
const res = await setupRequest({action: 'get', step: 'dns', mail_ipv4: mailIPv4})
|
||||||
assertSetupSuccess(res)
|
assertSetupSuccess(res)
|
||||||
dnsInfos.value = res.data
|
dnsInfos.value = res.data
|
||||||
|
dnsAppliedIPv4.value = mailIPv4
|
||||||
return res
|
return res
|
||||||
}).catch((error) => {
|
} catch (error) {
|
||||||
showSetupError(error)
|
dnsInfos.value = {}
|
||||||
|
dnsError.value = showSetupError(error)
|
||||||
return null
|
return null
|
||||||
})
|
} finally {
|
||||||
|
dnsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectDNSConfig = () => {
|
||||||
|
dnsMailIPv4.value = ''
|
||||||
|
return getDNSConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyDNSRecord = (record) => {
|
||||||
|
if (dnsLoading.value || dnsInputPending.value || record.unavailableReason || !record.value) return
|
||||||
|
return copyDNSValue(record.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyDNSValue = async (value) => {
|
const copyDNSValue = async (value) => {
|
||||||
@ -736,6 +763,13 @@ export function usePage() {
|
|||||||
port,
|
port,
|
||||||
proxyHTTPS,
|
proxyHTTPS,
|
||||||
dnsInfos,
|
dnsInfos,
|
||||||
|
dnsMailIPv4,
|
||||||
|
dnsAppliedIPv4,
|
||||||
|
dnsLoading,
|
||||||
|
dnsError,
|
||||||
|
dnsInputPending,
|
||||||
|
hasUnavailableDNS,
|
||||||
|
detectDNSConfig,
|
||||||
hasDNSIPv6,
|
hasDNSIPv6,
|
||||||
addDomain,
|
addDomain,
|
||||||
setPassword,
|
setPassword,
|
||||||
@ -748,6 +782,7 @@ export function usePage() {
|
|||||||
clearDatabaseError,
|
clearDatabaseError,
|
||||||
getDNSConfig,
|
getDNSConfig,
|
||||||
formatDNSRecordTTL,
|
formatDNSRecordTTL,
|
||||||
|
copyDNSRecord,
|
||||||
copyDNSValue,
|
copyDNSValue,
|
||||||
getSSLConfig,
|
getSSLConfig,
|
||||||
setSSLConfig,
|
setSSLConfig,
|
||||||
|
|||||||
@ -205,7 +205,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="Object.keys(dnsInfos).length > 0" class="dns-domain-list">
|
<div class="dns-address-controls">
|
||||||
|
<div class="dns-address-form">
|
||||||
|
<label for="dns-mail-ipv4">邮件服务器公网 IPv4</label>
|
||||||
|
<el-input
|
||||||
|
id="dns-mail-ipv4"
|
||||||
|
v-model="dnsMailIPv4"
|
||||||
|
class="dns-address-input"
|
||||||
|
placeholder="留空自动探测,失败时可手工填写"
|
||||||
|
:disabled="dnsLoading"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="getDNSConfig()"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="dnsLoading" @click="getDNSConfig()">生成记录</el-button>
|
||||||
|
<el-button :disabled="dnsLoading" @click="detectDNSConfig()">重新自动探测</el-button>
|
||||||
|
</div>
|
||||||
|
<p class="dns-address-note">仅生成解析建议,不会修改真实 DNS;后台地址独立解析,MX、SPF、DKIM、DMARC 仍需保留。</p>
|
||||||
|
<p v-if="dnsError" class="dns-address-warning" role="alert">{{ dnsError }}</p>
|
||||||
|
<p v-else-if="dnsInputPending" class="dns-address-warning" role="status">地址已修改,请生成记录后再复制。</p>
|
||||||
|
<p v-else-if="hasUnavailableDNS" class="dns-address-warning" role="status">部分记录尚未就绪,已禁用复制。请补充公网 IP 或检查后台域名解析;已自行配置 DNS 的用户仍可继续。</p>
|
||||||
|
<p v-else-if="dnsAppliedIPv4" class="dns-address-note" role="status">当前建议使用手工地址:{{ dnsAppliedIPv4 }}。请确认它是邮件服务器实际使用的公网地址。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="Object.keys(dnsInfos).length > 0" class="dns-domain-list" :aria-busy="dnsLoading">
|
||||||
<section class="dns-domain-card" v-for="(info, domain) in dnsInfos" :key="domain">
|
<section class="dns-domain-card" v-for="(info, domain) in dnsInfos" :key="domain">
|
||||||
<div class="dns-domain-header">
|
<div class="dns-domain-header">
|
||||||
<div>
|
<div>
|
||||||
@ -253,7 +275,10 @@
|
|||||||
>
|
>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="dns-value-cell">
|
<div class="dns-value-cell">
|
||||||
<el-tooltip :content="scope.row.tips" placement="top" v-if="scope.row.tips">
|
<span v-if="scope.row.unavailableReason || !scope.row.value" class="dns-value dns-value-unavailable">
|
||||||
|
{{ scope.row.unavailableReason || '记录尚未就绪' }}
|
||||||
|
</span>
|
||||||
|
<el-tooltip :content="scope.row.tips" placement="top" v-else-if="scope.row.tips">
|
||||||
<span :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
|
<span :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
|
||||||
{{ scope.row.value }}
|
{{ scope.row.value }}
|
||||||
</span>
|
</span>
|
||||||
@ -261,7 +286,13 @@
|
|||||||
<span v-else :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
|
<span v-else :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
|
||||||
{{ scope.row.value }}
|
{{ scope.row.value }}
|
||||||
</span>
|
</span>
|
||||||
<el-button class="dns-copy-button" link type="primary" @click.stop="copyDNSValue(scope.row.value)">
|
<el-button
|
||||||
|
class="dns-copy-button"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:disabled="dnsLoading || dnsInputPending || !!scope.row.unavailableReason || !scope.row.value"
|
||||||
|
@click.stop="copyDNSRecord(scope.row)"
|
||||||
|
>
|
||||||
{{ lang.dns_copy }}
|
{{ lang.dns_copy }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user