소스 검색

Merge claude/mystifying-kirch into main

urban 1 개월 전
부모
커밋
c7539525a0

+ 1 - 0
magic_admin/base/core/migrate.go

@@ -31,6 +31,7 @@ var allTables = []MigrateTable{
 // bizTables 业务库需要迁移的表
 var bizTables = []MigrateTable{
 	&bizApp.CfAccount{},
+	&bizApp.CfDnsTemplate{},
 }
 
 func Migrates() {

+ 23 - 0
magic_admin/model/biz_modules/app/cf_dns_template.go

@@ -0,0 +1,23 @@
+package app
+
+// CfDnsTemplate DNS 记录模板
+type CfDnsTemplate struct {
+	Id        int64  `json:"id" gorm:"column:id;type:bigint;primarykey;NOT NULL"`
+	CreatedAt int64  `json:"createdAt" gorm:"column:created_at;type:bigint;comment:创建时间"`
+	UpdatedAt int64  `json:"updatedAt" gorm:"column:updated_at;type:bigint;comment:更新时间"`
+	Name      string `json:"name" gorm:"column:name;type:varchar(100);comment:模板名称"`
+	Remark    string `json:"remark" gorm:"column:remark;type:varchar(500);comment:备注"`
+	Records   string `json:"records" gorm:"column:records;type:text;comment:DNS记录JSON"`
+}
+
+func (*CfDnsTemplate) TableName() string {
+	return "cf_dns_template"
+}
+
+func NewCfDnsTemplate() *CfDnsTemplate {
+	return &CfDnsTemplate{}
+}
+
+func (*CfDnsTemplate) Comment() string {
+	return "DNS记录模板表"
+}

+ 2 - 0
magic_admin/router/app/cf_dns.go

@@ -21,4 +21,6 @@ func (h CfDnsRouter) Register(group *gin.RouterGroup) {
 	group.POST("record/update", cfDnsService.UpdateRecord)
 	group.GET("record/delete", cfDnsService.DeleteRecord)
 	group.POST("record/toggle_proxy", cfDnsService.ToggleProxy)
+	group.POST("cross_zone/delete", cfDnsService.CrossZoneDeleteRecords)
+	group.POST("cross_zone/toggle_proxy", cfDnsService.CrossZoneToggleProxy)
 }

+ 22 - 0
magic_admin/router/app/cf_dns_template.go

@@ -0,0 +1,22 @@
+package app
+
+import (
+	"github.com/gin-gonic/gin"
+	"go_server/service"
+)
+
+type CfDnsTemplateRouter struct{}
+
+func (CfDnsTemplateRouter) Route() string {
+	return "/cf_dns_template"
+}
+
+var cfDnsTemplateService = service.RealizationLayer.AppServiceGroup.CfDnsTemplateService
+
+func (h CfDnsTemplateRouter) Register(group *gin.RouterGroup) {
+	group.GET("find", cfDnsTemplateService.Find)
+	group.GET("get", cfDnsTemplateService.Get)
+	group.POST("create", cfDnsTemplateService.Create)
+	group.POST("update", cfDnsTemplateService.Update)
+	group.GET("delete", cfDnsTemplateService.Delete)
+}

+ 1 - 1
magic_admin/router/app/enter.go

@@ -8,7 +8,7 @@ import (
 // All routes to be registered
 
 var (
-	allRouters = []global.ContextInterface{MagicAssetRouter{}, MagicAssetBillRouter{}, MagicAssetRwCallbackLogRouter{}, MagicAssetRwRecordRouter{}, MagicStakeProductRouter{}, MagicStakeQueueInfoRouter{}, MagicStakeUserCurrentOpsRecordRouter{}, MagicStakeUserCurrentOrderRouter{}, MagicUserRouter{}, MagicUserActionLogRouter{}, MagicUserLogsRouter{}, MagicUserProfitRouter{}, MagicUserProfitRecordRouter{}, MagicUserQuotaRouter{}, NodeBannerRouter{}, NodeInfoRouter{}, NodeOrderRouter{}, NodeOrderPaymentsRouter{}, PromotionUpgradeLevelRouter{}, SysCoinRouter{}, SysI18nRouter{}, SysJobRouter{}, SysLevelConfigRouter{}, SysSignConfigRouter{}, SysStakePeriodJobRouter{}, MagicExtraStakeOrderOpsRecordRouter{}, MagicExtraStakeOrderRouter{}, TgRedPacketConfigRouter{}, TgRedPacketSendRouter{}, TgRedPacketRecordRouter{}, TgGroupRouter{}, TgUserBindRouter{}, CfAccountRouter{}, CfDnsRouter{}}
+	allRouters = []global.ContextInterface{MagicAssetRouter{}, MagicAssetBillRouter{}, MagicAssetRwCallbackLogRouter{}, MagicAssetRwRecordRouter{}, MagicStakeProductRouter{}, MagicStakeQueueInfoRouter{}, MagicStakeUserCurrentOpsRecordRouter{}, MagicStakeUserCurrentOrderRouter{}, MagicUserRouter{}, MagicUserActionLogRouter{}, MagicUserLogsRouter{}, MagicUserProfitRouter{}, MagicUserProfitRecordRouter{}, MagicUserQuotaRouter{}, NodeBannerRouter{}, NodeInfoRouter{}, NodeOrderRouter{}, NodeOrderPaymentsRouter{}, PromotionUpgradeLevelRouter{}, SysCoinRouter{}, SysI18nRouter{}, SysJobRouter{}, SysLevelConfigRouter{}, SysSignConfigRouter{}, SysStakePeriodJobRouter{}, MagicExtraStakeOrderOpsRecordRouter{}, MagicExtraStakeOrderRouter{}, TgRedPacketConfigRouter{}, TgRedPacketSendRouter{}, TgRedPacketRecordRouter{}, TgGroupRouter{}, TgUserBindRouter{}, CfAccountRouter{}, CfDnsRouter{}, CfDnsTemplateRouter{}}
 )
 
 type RouterGroup struct {

+ 163 - 8
magic_admin/service/app/cf_dns.go

@@ -57,7 +57,7 @@ func (s *CfDnsService) getToken(accountId int64) (string, bool) {
 	return account.ApiToken, true
 }
 
-// Zones 获取域名列表
+// Zones 获取域名列表(自动翻页,返回全部)
 func (s *CfDnsService) Zones(c *gin.Context) {
 	s.SetDbAlias("app")
 	accountId := gocast.ToInt64(c.Query("account_id"))
@@ -71,15 +71,41 @@ func (s *CfDnsService) Zones(c *gin.Context) {
 		return
 	}
 
-	data, err := s.cfRequest("GET", cfAPIBase+"/zones?per_page=50", token, nil)
-	if err != nil {
-		response.Resp(c, err.Error())
-		return
+	var allZones []interface{}
+	page := 1
+	for {
+		url := fmt.Sprintf("%s/zones?per_page=50&page=%d", cfAPIBase, page)
+		data, err := s.cfRequest("GET", url, token, nil)
+		if err != nil {
+			response.Resp(c, err.Error())
+			return
+		}
+		var result map[string]interface{}
+		if err := json.Unmarshal(data, &result); err != nil {
+			response.Resp(c, err.Error())
+			return
+		}
+		cfSuccess, _ := result["success"].(bool)
+		if !cfSuccess {
+			response.Resp(c, result)
+			return
+		}
+		zones, _ := result["result"].([]interface{})
+		allZones = append(allZones, zones...)
+
+		resultInfo, _ := result["result_info"].(map[string]interface{})
+		totalPages := gocast.ToInt64(resultInfo["total_pages"])
+		if int64(page) >= totalPages {
+			break
+		}
+		page++
 	}
 
-	var result map[string]interface{}
-	json.Unmarshal(data, &result)
-	response.Resp(c, result)
+	response.Resp(c, map[string]interface{}{
+		"result":      allZones,
+		"total_count": len(allZones),
+		"success":     true,
+	})
 }
 
 // Records 获取 DNS 记录列表
@@ -305,6 +331,135 @@ func (s *CfDnsService) DeleteRecord(c *gin.Context) {
 	response.Resp(c, result)
 }
 
+// getZoneRecords 获取 zone 的所有 DNS 记录(按 type/name 可选过滤)
+func (s *CfDnsService) getZoneRecords(zoneId, token, recType, name string) ([]map[string]interface{}, error) {
+	query := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", cfAPIBase, zoneId)
+	if recType != "" {
+		query += "&type=" + recType
+	}
+	if name != "" {
+		query += "&name=" + name
+	}
+	data, err := s.cfRequest("GET", query, token, nil)
+	if err != nil {
+		return nil, err
+	}
+	var result map[string]interface{}
+	json.Unmarshal(data, &result)
+	raw, _ := result["result"].([]interface{})
+	var records []map[string]interface{}
+	for _, r := range raw {
+		if rec, ok := r.(map[string]interface{}); ok {
+			records = append(records, rec)
+		}
+	}
+	return records, nil
+}
+
+// CrossZoneDeleteRecords 跨域名批量删除匹配的 DNS 记录
+func (s *CfDnsService) CrossZoneDeleteRecords(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request struct {
+		AccountId int64    `json:"accountId" binding:"required"`
+		ZoneIds   []string `json:"zoneIds" binding:"required"`
+		Type      string   `json:"type"`
+		Name      string   `json:"name"`
+	}
+	req := new(request)
+	if err := c.BindJSON(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	if req.Type == "" && req.Name == "" {
+		response.Resp(c, "type 和 name 至少填一个")
+		return
+	}
+	token, ok := s.getToken(req.AccountId)
+	if !ok {
+		response.Resp(c, "账号不存在或已禁用")
+		return
+	}
+
+	var deleted, failed int
+	for _, zoneId := range req.ZoneIds {
+		records, err := s.getZoneRecords(zoneId, token, req.Type, req.Name)
+		if err != nil {
+			failed++
+			continue
+		}
+		for _, rec := range records {
+			recordId, _ := rec["id"].(string)
+			if recordId == "" {
+				continue
+			}
+			delURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneId, recordId)
+			if _, err := s.cfRequest("DELETE", delURL, token, nil); err != nil {
+				failed++
+			} else {
+				deleted++
+			}
+		}
+	}
+	response.Resp(c, map[string]interface{}{
+		"zones":   len(req.ZoneIds),
+		"deleted": deleted,
+		"fail":    failed,
+	})
+}
+
+// CrossZoneToggleProxy 跨域名批量切换橙云
+func (s *CfDnsService) CrossZoneToggleProxy(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request struct {
+		AccountId int64    `json:"accountId" binding:"required"`
+		ZoneIds   []string `json:"zoneIds" binding:"required"`
+		Type      string   `json:"type"`
+		Name      string   `json:"name"`
+		Proxied   bool     `json:"proxied"`
+	}
+	req := new(request)
+	if err := c.BindJSON(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	token, ok := s.getToken(req.AccountId)
+	if !ok {
+		response.Resp(c, "账号不存在或已禁用")
+		return
+	}
+
+	var updated, failed int
+	for _, zoneId := range req.ZoneIds {
+		records, err := s.getZoneRecords(zoneId, token, req.Type, req.Name)
+		if err != nil {
+			failed++
+			continue
+		}
+		for _, rec := range records {
+			recordId, _ := rec["id"].(string)
+			recType, _ := rec["type"].(string)
+			if recordId == "" {
+				continue
+			}
+			// 只有 A/AAAA/CNAME 支持 proxied
+			if recType != "A" && recType != "AAAA" && recType != "CNAME" {
+				continue
+			}
+			patchURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneId, recordId)
+			if _, err := s.cfRequest("PATCH", patchURL, token, map[string]interface{}{"proxied": req.Proxied}); err != nil {
+				failed++
+			} else {
+				updated++
+			}
+		}
+	}
+	response.Resp(c, map[string]interface{}{
+		"zones":   len(req.ZoneIds),
+		"updated": updated,
+		"fail":    failed,
+	})
+}
+
 // ToggleProxy 批量切换橙云(proxied)
 func (s *CfDnsService) ToggleProxy(c *gin.Context) {
 	s.SetDbAlias("app")

+ 130 - 0
magic_admin/service/app/cf_dns_template.go

@@ -0,0 +1,130 @@
+package app
+
+import (
+	"time"
+
+	"github.com/demdxx/gocast"
+	"github.com/gin-gonic/gin"
+	"go_server/model/biz_modules/app"
+	"go_server/model/common/response"
+	"go_server/service/base"
+)
+
+type CfDnsTemplateService struct {
+	base.BizCommonService
+}
+
+// Find 查询模板列表
+func (s *CfDnsTemplateService) Find(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request[T any] struct {
+		base.ListRequest[T]
+		Name string `form:"name"`
+	}
+	req := new(request[app.CfDnsTemplate])
+	if err := c.BindQuery(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	db := s.DB()
+	if req.Name != "" {
+		db = db.Where("name LIKE ?", "%"+req.Name+"%")
+	}
+	db = db.Order("created_at DESC")
+	resp, err := base.NewQueryBaseHandler(app.NewCfDnsTemplate()).List(db, req)
+	if err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	response.Resp(c, resp)
+}
+
+// Get 获取单个模板
+func (s *CfDnsTemplateService) Get(c *gin.Context) {
+	s.SetDbAlias("app")
+	base.NewBaseHandler(app.NewCfDnsTemplate()).Get(c, s.DB())
+}
+
+// Create 创建模板
+func (s *CfDnsTemplateService) Create(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request struct {
+		Name    string `json:"name" binding:"required"`
+		Remark  string `json:"remark"`
+		Records string `json:"records" binding:"required"`
+	}
+	req := new(request)
+	if err := c.BindJSON(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	now := time.Now().Unix()
+	tpl := &app.CfDnsTemplate{
+		Name:      req.Name,
+		Remark:    req.Remark,
+		Records:   req.Records,
+		CreatedAt: now,
+		UpdatedAt: now,
+	}
+	if err := s.DB().Create(tpl).Error; err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	response.Resp(c, tpl)
+}
+
+// Update 更新模板
+func (s *CfDnsTemplateService) Update(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request struct {
+		Id      int64  `json:"id" binding:"required"`
+		Name    string `json:"name"`
+		Remark  string `json:"remark"`
+		Records string `json:"records"`
+	}
+	req := new(request)
+	if err := c.BindJSON(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	tpl, ok := base.GetOne[app.CfDnsTemplate](s.DB(), "id", req.Id)
+	if !ok {
+		response.Resp(c, "模板不存在")
+		return
+	}
+	updates := map[string]interface{}{"updated_at": time.Now().Unix()}
+	if req.Name != "" {
+		updates["name"] = req.Name
+	}
+	if req.Remark != "" {
+		updates["remark"] = req.Remark
+	}
+	if req.Records != "" {
+		updates["records"] = req.Records
+	}
+	if err := s.DB().Model(tpl).Updates(updates).Error; err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	response.Resp(c)
+}
+
+// Delete 删除模板
+func (s *CfDnsTemplateService) Delete(c *gin.Context) {
+	s.SetDbAlias("app")
+	id := gocast.ToInt64(c.Query("id"))
+	if id == 0 {
+		response.Resp(c, "id 必填")
+		return
+	}
+	tpl, ok := base.GetOne[app.CfDnsTemplate](s.DB(), "id", id)
+	if !ok {
+		response.Resp(c, "模板不存在")
+		return
+	}
+	if err := s.DB().Delete(tpl).Error; err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	response.Resp(c)
+}

+ 1 - 0
magic_admin/service/app/enter.go

@@ -35,4 +35,5 @@ type ServiceGroup struct {
 	TgUserBindService
 	CfAccountService
 	CfDnsService
+	CfDnsTemplateService
 }

+ 10 - 0
magic_admin_web/src/api/modules/cloudflare.js

@@ -20,3 +20,13 @@ export const batchCreateCfRecord = data => http.post(`${DNS}/record/batch_create
 export const updateCfRecord = data => http.post(`${DNS}/record/update`, data);
 export const deleteCfRecord = params => http.get(`${DNS}/record/delete`, params);
 export const toggleCfProxy = data => http.post(`${DNS}/record/toggle_proxy`, data);
+export const crossZoneDeleteRecords = data => http.post(`${DNS}/cross_zone/delete`, data);
+export const crossZoneToggleProxy = data => http.post(`${DNS}/cross_zone/toggle_proxy`, data);
+
+// ==================== DNS 模板 ====================
+const TEMPLATE = "/admin/api/app/cf_dns_template";
+export const getDnsTemplateList = params => http.get(`${TEMPLATE}/find`, params);
+export const getDnsTemplate = id => http.get(`${TEMPLATE}/get`, { id });
+export const createDnsTemplate = data => http.post(`${TEMPLATE}/create`, data);
+export const updateDnsTemplate = data => http.post(`${TEMPLATE}/update`, data);
+export const deleteDnsTemplate = id => http.get(`${TEMPLATE}/delete`, { id });

+ 12 - 0
magic_admin_web/src/routers/modules/staticRouter.js

@@ -94,10 +94,22 @@ export const staticRouter = [
             meta: { title: "账号管理", icon: "Key", isKeepAlive: true }
           },
           {
+            path: "zones",
+            name: "cfZones",
+            component: () => import("@/views/cloudflare/zones/index.vue"),
+            meta: { title: "Zone 列表", icon: "List", isKeepAlive: true }
+          },
+          {
             path: "dns",
             name: "cfDns",
             component: () => import("@/views/cloudflare/dns/index.vue"),
             meta: { title: "DNS 管理", icon: "Connection", isKeepAlive: true }
+          },
+          {
+            path: "template",
+            name: "cfDnsTemplate",
+            component: () => import("@/views/cloudflare/template/index.vue"),
+            meta: { title: "DNS 模板", icon: "Document", isKeepAlive: true }
           }
         ]
       },

+ 279 - 84
magic_admin_web/src/views/cloudflare/dns/index.vue

@@ -2,35 +2,33 @@
   <div class="page-container">
     <!-- 顶部选择器 -->
     <el-card class="selector-card">
-      <el-row :gutter="16" align="middle">
-        <el-col :span="6">
+      <el-row :gutter="12" align="middle">
+        <el-col :span="4">
           <el-select v-model="selectedAccountId" placeholder="选择 CF 账号" @change="onAccountChange" style="width:100%">
             <el-option v-for="a in accounts" :key="a.id" :label="a.name" :value="a.id" />
           </el-select>
         </el-col>
-        <el-col :span="8">
-          <el-select v-model="selectedZoneId" placeholder="选择域名 (Zone)" :disabled="!selectedAccountId" @change="loadRecords" style="width:100%">
+        <el-col :span="6">
+          <el-select v-model="selectedZoneId" placeholder="选择域名 (Zone)" :disabled="!selectedAccountId" @change="loadRecords" filterable style="width:100%">
             <el-option v-for="z in zones" :key="z.id" :label="z.name" :value="z.id" />
           </el-select>
         </el-col>
-        <el-col :span="10">
+        <el-col :span="14" style="display:flex;flex-wrap:wrap;gap:6px">
           <el-button type="primary" :disabled="!selectedZoneId" @click="showAddDialog">新增记录</el-button>
+          <el-button :disabled="!selectedZoneId" @click="showSingleBatchDialog">批量新增</el-button>
+          <el-button type="success" :disabled="zones.length === 0" @click="showCrossBatchDialog">跨域名批量新增</el-button>
+          <el-button type="danger" :disabled="zones.length === 0" @click="showCrossDeleteDialog">跨域名批量删除</el-button>
+          <el-button type="warning" :disabled="zones.length === 0" @click="showCrossProxyDialog">跨域名橙云切换</el-button>
           <el-button :disabled="!selectedZoneId" @click="loadRecords">刷新</el-button>
-          <el-button type="warning" :disabled="selectedIds.length === 0" @click="showBatchProxy(true)">批量开橙云</el-button>
-          <el-button type="info" :disabled="selectedIds.length === 0" @click="showBatchProxy(false)">批量关橙云</el-button>
+          <el-button type="warning" plain :disabled="selectedIds.length === 0" @click="showBatchProxy(true)">批量开橙云</el-button>
+          <el-button type="info" plain :disabled="selectedIds.length === 0" @click="showBatchProxy(false)">批量关橙云</el-button>
         </el-col>
       </el-row>
     </el-card>
 
     <!-- DNS 记录表格 -->
     <el-card class="table-card">
-      <el-table
-        v-loading="loading"
-        :data="records"
-        @selection-change="onSelectionChange"
-        border
-        stripe
-      >
+      <el-table v-loading="loading" :data="records" @selection-change="onSelectionChange" border stripe>
         <el-table-column type="selection" width="50" />
         <el-table-column prop="type" label="类型" width="80" />
         <el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
@@ -40,9 +38,7 @@
         </el-table-column>
         <el-table-column label="橙云" width="80">
           <template #default="{ row }">
-            <el-tag :type="row.proxied ? 'warning' : 'info'" size="small">
-              {{ row.proxied ? '开启' : '关闭' }}
-            </el-tag>
+            <el-tag :type="row.proxied ? 'warning' : 'info'" size="small">{{ row.proxied ? '开启' : '关闭' }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
@@ -52,13 +48,10 @@
           </template>
         </el-table-column>
       </el-table>
-
-      <div class="batch-tip" v-if="selectedIds.length > 0">
-        已选 {{ selectedIds.length }} 条记录
-      </div>
+      <div class="batch-tip" v-if="selectedIds.length > 0">已选 {{ selectedIds.length }} 条记录</div>
     </el-card>
 
-    <!-- 新增/编辑记录对话框 -->
+    <!-- 新增/编辑记录 -->
     <el-dialog v-model="recordDialog" :title="editRecord ? '编辑 DNS 记录' : '新增 DNS 记录'" width="520px">
       <el-form ref="recordFormRef" :model="recordForm" :rules="recordRules" label-width="90px">
         <el-form-item label="类型" prop="type">
@@ -66,23 +59,15 @@
             <el-option v-for="t in dnsTypes" :key="t" :label="t" :value="t" />
           </el-select>
         </el-form-item>
-        <el-form-item label="名称" prop="name">
-          <el-input v-model="recordForm.name" placeholder="如 @ 或 sub" />
-        </el-form-item>
-        <el-form-item label="内容" prop="content">
-          <el-input v-model="recordForm.content" placeholder="如 1.2.3.4" />
-        </el-form-item>
+        <el-form-item label="名称" prop="name"><el-input v-model="recordForm.name" placeholder="如 @ 或 sub" /></el-form-item>
+        <el-form-item label="内容" prop="content"><el-input v-model="recordForm.content" placeholder="如 1.2.3.4" /></el-form-item>
         <el-form-item label="TTL">
           <el-select v-model="recordForm.ttl" style="width:100%">
-            <el-option label="Auto" :value="1" />
-            <el-option label="60s" :value="60" />
-            <el-option label="5min" :value="300" />
-            <el-option label="1h" :value="3600" />
+            <el-option label="Auto" :value="1" /><el-option label="60s" :value="60" />
+            <el-option label="5min" :value="300" /><el-option label="1h" :value="3600" />
           </el-select>
         </el-form-item>
-        <el-form-item label="橙云代理">
-          <el-switch v-model="recordForm.proxied" />
-        </el-form-item>
+        <el-form-item label="橙云代理"><el-switch v-model="recordForm.proxied" /></el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="recordDialog = false">取消</el-button>
@@ -90,35 +75,127 @@
       </template>
     </el-dialog>
 
-    <!-- 批量创建对话框 -->
-    <el-dialog v-model="batchDialog" title="批量新增 DNS 记录" width="680px">
-      <p class="batch-tip-text">每行一条记录,格式:<code>类型 名称 内容 [proxied]</code>,例如:</p>
-      <pre class="batch-example">A  app  1.2.3.4  true
-A  api  1.2.3.5
-CNAME  www  example.com  true</pre>
-      <el-input
-        v-model="batchText"
-        type="textarea"
-        :rows="10"
-        placeholder="A  sub  1.2.3.4  true"
-      />
+    <!-- 当前域名批量新增 -->
+    <el-dialog v-model="batchDialog" title="批量新增 DNS 记录(当前域名)" width="660px">
+      <p class="tip-text">每行一条,格式:<code>类型 名称 内容 [proxied]</code></p>
+      <pre class="tip-example">A  @  1.2.3.4  true
+CNAME  www  example.com</pre>
+      <el-input v-model="batchText" type="textarea" :rows="10" placeholder="A  @  1.2.3.4  true" />
       <template #footer>
         <el-button @click="batchDialog = false">取消</el-button>
         <el-button type="primary" @click="submitBatch">批量提交</el-button>
       </template>
     </el-dialog>
+
+    <!-- 跨域名批量新增 -->
+    <el-dialog v-model="crossBatchDialog" title="跨域名批量新增 DNS 记录" width="760px">
+      <el-form label-width="90px">
+        <el-form-item label="选择域名">
+          <div style="width:100%">
+            <div class="zone-actions">
+              <el-button size="small" @click="crossBatchZoneIds = zones.map(z=>z.id)">全选</el-button>
+              <el-button size="small" @click="crossBatchZoneIds = []">清空</el-button>
+              <span class="zone-count">已选 {{ crossBatchZoneIds.length }} / {{ zones.length }}</span>
+            </div>
+            <el-select v-model="crossBatchZoneIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择域名" style="width:100%">
+              <el-option v-for="z in zones" :key="z.id" :label="z.name" :value="z.id" />
+            </el-select>
+          </div>
+        </el-form-item>
+        <el-form-item label="DNS 记录">
+          <p class="tip-text" style="margin:0 0 4px">每行一条,格式:<code>类型 名称 内容 [proxied]</code></p>
+          <el-input v-model="crossBatchText" type="textarea" :rows="7" placeholder="A  @  1.2.3.4  true" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <ProgressBlock v-if="progress.show" :progress="progress" />
+      <template #footer>
+        <el-button @click="crossBatchDialog = false" :disabled="progress.running">取消</el-button>
+        <el-button type="primary" @click="submitCrossBatch" :loading="progress.running">开始添加</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 跨域名批量删除 -->
+    <el-dialog v-model="crossDeleteDialog" title="跨域名批量删除 DNS 记录" width="660px">
+      <el-alert type="warning" :closable="false" style="margin-bottom:16px">
+        <template #title>此操作将删除所选域名中匹配条件的所有 DNS 记录,不可恢复!</template>
+      </el-alert>
+      <el-form label-width="90px">
+        <el-form-item label="选择域名">
+          <div style="width:100%">
+            <div class="zone-actions">
+              <el-button size="small" @click="crossDeleteZoneIds = zones.map(z=>z.id)">全选</el-button>
+              <el-button size="small" @click="crossDeleteZoneIds = []">清空</el-button>
+              <span class="zone-count">已选 {{ crossDeleteZoneIds.length }} / {{ zones.length }}</span>
+            </div>
+            <el-select v-model="crossDeleteZoneIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择域名" style="width:100%">
+              <el-option v-for="z in zones" :key="z.id" :label="z.name" :value="z.id" />
+            </el-select>
+          </div>
+        </el-form-item>
+        <el-form-item label="记录类型"><el-select v-model="crossDeleteType" placeholder="不限" clearable style="width:100%"><el-option v-for="t in dnsTypes" :key="t" :label="t" :value="t" /></el-select></el-form-item>
+        <el-form-item label="记录名称"><el-input v-model="crossDeleteName" placeholder="如 @ 或 www,不填则匹配所有" /></el-form-item>
+      </el-form>
+      <ProgressBlock v-if="delProgress.show" :progress="delProgress" />
+      <template #footer>
+        <el-button @click="crossDeleteDialog = false" :disabled="delProgress.running">取消</el-button>
+        <el-button type="danger" @click="submitCrossDelete" :loading="delProgress.running">确认删除</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 跨域名橙云切换 -->
+    <el-dialog v-model="crossProxyDialog" title="跨域名批量切换橙云" width="660px">
+      <el-form label-width="90px">
+        <el-form-item label="选择域名">
+          <div style="width:100%">
+            <div class="zone-actions">
+              <el-button size="small" @click="crossProxyZoneIds = zones.map(z=>z.id)">全选</el-button>
+              <el-button size="small" @click="crossProxyZoneIds = []">清空</el-button>
+              <span class="zone-count">已选 {{ crossProxyZoneIds.length }} / {{ zones.length }}</span>
+            </div>
+            <el-select v-model="crossProxyZoneIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择域名" style="width:100%">
+              <el-option v-for="z in zones" :key="z.id" :label="z.name" :value="z.id" />
+            </el-select>
+          </div>
+        </el-form-item>
+        <el-form-item label="记录类型"><el-select v-model="crossProxyType" placeholder="不限(只处理A/AAAA/CNAME)" clearable style="width:100%"><el-option v-for="t in ['A','AAAA','CNAME']" :key="t" :label="t" :value="t" /></el-select></el-form-item>
+        <el-form-item label="记录名称"><el-input v-model="crossProxyName" placeholder="不填则匹配所有" /></el-form-item>
+        <el-form-item label="橙云状态">
+          <el-radio-group v-model="crossProxyEnabled">
+            <el-radio :value="true">开启橙云</el-radio>
+            <el-radio :value="false">关闭橙云</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <ProgressBlock v-if="proxyProgress.show" :progress="proxyProgress" />
+      <template #footer>
+        <el-button @click="crossProxyDialog = false" :disabled="proxyProgress.running">取消</el-button>
+        <el-button type="primary" @click="submitCrossProxy" :loading="proxyProgress.running">确认执行</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue';
-import { ElMessage, ElMessageBox } from 'element-plus';
+import { ref, onMounted, defineComponent, h } from 'vue';
+import { ElMessage, ElMessageBox, ElProgress } from 'element-plus';
 import {
   getCfAccountList, getCfZones, getCfRecords,
   createCfRecord, batchCreateCfRecord, updateCfRecord,
-  deleteCfRecord, toggleCfProxy
+  deleteCfRecord, toggleCfProxy,
+  crossZoneDeleteRecords, crossZoneToggleProxy
 } from '@/api/modules/cloudflare';
 
+// 进度条组件
+const ProgressBlock = defineComponent({
+  props: { progress: Object },
+  setup(props) {
+    return () => h('div', { class: 'progress-block' }, [
+      h(ElProgress, { percentage: props.progress.pct, status: props.progress.pct === 100 ? 'success' : '' }),
+      h('p', { class: 'progress-msg' }, props.progress.msg)
+    ]);
+  }
+});
+
 const accounts = ref([]);
 const zones = ref([]);
 const records = ref([]);
@@ -129,12 +206,33 @@ const loading = ref(false);
 
 const recordDialog = ref(false);
 const batchDialog = ref(false);
+const crossBatchDialog = ref(false);
+const crossDeleteDialog = ref(false);
+const crossProxyDialog = ref(false);
+
 const editRecord = ref(null);
 const recordFormRef = ref();
 const batchText = ref('');
 
-const dnsTypes = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV', 'CAA'];
+// 跨域名批量新增
+const crossBatchZoneIds = ref([]);
+const crossBatchText = ref('');
+const progress = ref({ show: false, running: false, pct: 0, msg: '' });
+
+// 跨域名批量删除
+const crossDeleteZoneIds = ref([]);
+const crossDeleteType = ref('');
+const crossDeleteName = ref('');
+const delProgress = ref({ show: false, running: false, pct: 0, msg: '' });
 
+// 跨域名橙云切换
+const crossProxyZoneIds = ref([]);
+const crossProxyType = ref('');
+const crossProxyName = ref('');
+const crossProxyEnabled = ref(true);
+const proxyProgress = ref({ show: false, running: false, pct: 0, msg: '' });
+
+const dnsTypes = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV', 'CAA'];
 const recordForm = ref({ type: 'A', name: '', content: '', ttl: 1, proxied: false });
 const recordRules = {
   type: [{ required: true, message: '请选择类型' }],
@@ -151,28 +249,69 @@ async function onAccountChange() {
   selectedZoneId.value = null;
   zones.value = [];
   records.value = [];
+  crossBatchZoneIds.value = [];
+  crossDeleteZoneIds.value = [];
+  crossProxyZoneIds.value = [];
   if (!selectedAccountId.value) return;
-  const res = await getCfZones(selectedAccountId.value);
-  zones.value = res?.data?.result || [];
+  try {
+    const res = await getCfZones(selectedAccountId.value);
+    const result = res?.data?.result;
+    if (!Array.isArray(result)) {
+      ElMessage.error('获取域名失败:' + (res?.data?.errors?.[0]?.message || JSON.stringify(res?.data)));
+      return;
+    }
+    zones.value = result;
+    ElMessage.success(`已加载 ${zones.value.length} 个域名`);
+  } catch (e) {
+    ElMessage.error('获取域名异常:' + e.message);
+  }
 }
 
 async function loadRecords() {
   if (!selectedZoneId.value) return;
   loading.value = true;
-  const res = await getCfRecords({ account_id: selectedAccountId.value, zone_id: selectedZoneId.value });
-  records.value = res?.data?.result || [];
-  loading.value = false;
+  try {
+    const res = await getCfRecords({ account_id: selectedAccountId.value, zone_id: selectedZoneId.value });
+    const result = res?.data?.result;
+    records.value = Array.isArray(result) ? result : [];
+    if (!Array.isArray(result)) ElMessage.error('获取记录失败:' + JSON.stringify(res?.data));
+  } catch (e) {
+    ElMessage.error('获取记录异常:' + e.message);
+    records.value = [];
+  } finally {
+    loading.value = false;
+  }
 }
 
-function onSelectionChange(rows) {
-  selectedIds.value = rows.map(r => r.id);
-}
+function onSelectionChange(rows) { selectedIds.value = rows.map(r => r.id); }
 
 function showAddDialog() {
   editRecord.value = null;
   recordForm.value = { type: 'A', name: '', content: '', ttl: 1, proxied: false };
   recordDialog.value = true;
 }
+function showSingleBatchDialog() { batchText.value = ''; batchDialog.value = true; }
+function showCrossBatchDialog() {
+  crossBatchText.value = '';
+  crossBatchZoneIds.value = [];
+  progress.value = { show: false, running: false, pct: 0, msg: '' };
+  crossBatchDialog.value = true;
+}
+function showCrossDeleteDialog() {
+  crossDeleteZoneIds.value = [];
+  crossDeleteType.value = '';
+  crossDeleteName.value = '';
+  delProgress.value = { show: false, running: false, pct: 0, msg: '' };
+  crossDeleteDialog.value = true;
+}
+function showCrossProxyDialog() {
+  crossProxyZoneIds.value = [];
+  crossProxyType.value = '';
+  crossProxyName.value = '';
+  crossProxyEnabled.value = true;
+  proxyProgress.value = { show: false, running: false, pct: 0, msg: '' };
+  crossProxyDialog.value = true;
+}
 
 function handleEdit(row) {
   editRecord.value = row;
@@ -190,12 +329,7 @@ async function handleDelete(row) {
 async function submitRecord() {
   await recordFormRef.value.validate();
   if (editRecord.value) {
-    await updateCfRecord({
-      accountId: selectedAccountId.value,
-      zoneId: selectedZoneId.value,
-      recordId: editRecord.value.id,
-      ...recordForm.value
-    });
+    await updateCfRecord({ accountId: selectedAccountId.value, zoneId: selectedZoneId.value, recordId: editRecord.value.id, ...recordForm.value });
     ElMessage.success('更新成功');
   } else {
     await createCfRecord({ accountId: selectedAccountId.value, zoneId: selectedZoneId.value, ...recordForm.value });
@@ -205,41 +339,102 @@ async function submitRecord() {
   loadRecords();
 }
 
-async function submitBatch() {
-  const lines = batchText.value.trim().split('\n').filter(l => l.trim());
-  const recordsList = lines.map(line => {
+function parseRecordsText(text) {
+  return text.trim().split('\n').filter(l => l.trim()).map(line => {
     const parts = line.trim().split(/\s+/);
-    return {
-      type: parts[0] || 'A',
-      name: parts[1] || '',
-      content: parts[2] || '',
-      ttl: 1,
-      proxied: parts[3] === 'true'
-    };
+    return { type: parts[0] || 'A', name: parts[1] || '', content: parts[2] || '', ttl: 1, proxied: parts[3] === 'true' };
   }).filter(r => r.name && r.content);
+}
 
-  if (recordsList.length === 0) { ElMessage.warning('没有有效记录'); return; }
-
-  const res = await batchCreateCfRecord({ accountId: selectedAccountId.value, zoneId: selectedZoneId.value, records: recordsList });
-  ElMessage.success(`提交完成:成功 ${res?.data?.success},失败 ${res?.data?.fail}`);
+async function submitBatch() {
+  const list = parseRecordsText(batchText.value);
+  if (!list.length) { ElMessage.warning('没有有效记录'); return; }
+  const res = await batchCreateCfRecord({ accountId: selectedAccountId.value, zoneId: selectedZoneId.value, records: list });
+  ElMessage.success(`完成:成功 ${res?.data?.success},失败 ${res?.data?.fail}`);
   batchDialog.value = false;
   batchText.value = '';
   loadRecords();
 }
 
+async function submitCrossBatch() {
+  if (!crossBatchZoneIds.value.length) { ElMessage.warning('请选择至少一个域名'); return; }
+  const list = parseRecordsText(crossBatchText.value);
+  if (!list.length) { ElMessage.warning('没有有效记录'); return; }
+  const total = crossBatchZoneIds.value.length;
+  progress.value = { show: true, running: true, pct: 0, msg: '开始处理...' };
+  let ok = 0, fail = 0;
+  for (let i = 0; i < crossBatchZoneIds.value.length; i++) {
+    const zoneId = crossBatchZoneIds.value[i];
+    const zoneName = zones.value.find(z => z.id === zoneId)?.name || zoneId;
+    progress.value.msg = `处理 ${zoneName} (${i + 1}/${total})`;
+    try {
+      const res = await batchCreateCfRecord({ accountId: selectedAccountId.value, zoneId, records: list });
+      ok += res?.data?.success || 0;
+      fail += res?.data?.fail || 0;
+    } catch { fail += list.length; }
+    progress.value.pct = Math.round(((i + 1) / total) * 100);
+  }
+  progress.value = { show: true, running: false, pct: 100, msg: `完成:${total} 个域名,成功 ${ok} 条,失败 ${fail} 条` };
+  ElMessage.success(progress.value.msg);
+  if (selectedZoneId.value && crossBatchZoneIds.value.includes(selectedZoneId.value)) loadRecords();
+}
+
+async function submitCrossDelete() {
+  if (!crossDeleteZoneIds.value.length) { ElMessage.warning('请选择至少一个域名'); return; }
+  if (!crossDeleteType.value && !crossDeleteName.value) { ElMessage.warning('类型和名称至少填一个'); return; }
+  await ElMessageBox.confirm(`确定删除 ${crossDeleteZoneIds.value.length} 个域名中匹配的 DNS 记录?此操作不可恢复!`, '危险操作', { type: 'error' });
+  delProgress.value = { show: true, running: true, pct: 0, msg: '正在删除...' };
+  try {
+    const res = await crossZoneDeleteRecords({
+      accountId: selectedAccountId.value,
+      zoneIds: crossDeleteZoneIds.value,
+      type: crossDeleteType.value,
+      name: crossDeleteName.value
+    });
+    delProgress.value = { show: true, running: false, pct: 100, msg: `完成:删除 ${res?.data?.deleted} 条,失败 ${res?.data?.fail} 条` };
+    ElMessage.success(delProgress.value.msg);
+  } catch (e) {
+    delProgress.value.running = false;
+    ElMessage.error('操作失败:' + e.message);
+  }
+  if (selectedZoneId.value && crossDeleteZoneIds.value.includes(selectedZoneId.value)) loadRecords();
+}
+
+async function submitCrossProxy() {
+  if (!crossProxyZoneIds.value.length) { ElMessage.warning('请选择至少一个域名'); return; }
+  proxyProgress.value = { show: true, running: true, pct: 0, msg: '正在处理...' };
+  try {
+    const res = await crossZoneToggleProxy({
+      accountId: selectedAccountId.value,
+      zoneIds: crossProxyZoneIds.value,
+      type: crossProxyType.value,
+      name: crossProxyName.value,
+      proxied: crossProxyEnabled.value
+    });
+    proxyProgress.value = { show: true, running: false, pct: 100, msg: `完成:更新 ${res?.data?.updated} 条,失败 ${res?.data?.fail} 条` };
+    ElMessage.success(proxyProgress.value.msg);
+  } catch (e) {
+    proxyProgress.value.running = false;
+    ElMessage.error('操作失败:' + e.message);
+  }
+  if (selectedZoneId.value && crossProxyZoneIds.value.includes(selectedZoneId.value)) loadRecords();
+}
+
 async function showBatchProxy(proxied) {
   await ElMessageBox.confirm(`确定${proxied ? '开启' : '关闭'}选中 ${selectedIds.value.length} 条记录的橙云代理?`, '提示', { type: 'warning' });
   const res = await toggleCfProxy({ accountId: selectedAccountId.value, zoneId: selectedZoneId.value, recordIds: selectedIds.value, proxied });
-  ElMessage.success(`操作完成:成功 ${res?.data?.success},失败 ${res?.data?.fail}`);
+  ElMessage.success(`完成:成功 ${res?.data?.success},失败 ${res?.data?.fail}`);
   loadRecords();
 }
 </script>
 
 <style scoped lang="scss">
 .page-container { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
-.selector-card {}
-.table-card {}
 .batch-tip { margin-top: 12px; color: #666; font-size: 13px; }
-.batch-tip-text { color: #666; margin-bottom: 8px; }
-.batch-example { background: #f5f5f5; padding: 10px; border-radius: 4px; font-size: 13px; margin-bottom: 10px; }
+.tip-text { color: #666; margin-bottom: 6px; font-size: 13px; }
+.tip-example { background: #f5f5f5; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 10px; }
+.zone-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+.zone-count { color: #666; font-size: 13px; }
+.progress-block { margin-top: 16px; }
+.progress-msg { margin: 8px 0 0; color: #666; font-size: 13px; }
 </style>

+ 257 - 0
magic_admin_web/src/views/cloudflare/template/index.vue

@@ -0,0 +1,257 @@
+<template>
+  <div class="page-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span class="title">DNS 记录模板</span>
+          <el-button type="primary" @click="handleAdd">新增模板</el-button>
+        </div>
+      </template>
+
+      <el-table v-loading="loading" :data="templates" border stripe>
+        <el-table-column prop="name" label="模板名称" min-width="160" />
+        <el-table-column label="记录数" width="90">
+          <template #default="{ row }">{{ parseRecords(row.records).length }} 条</template>
+        </el-table-column>
+        <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
+        <el-table-column label="创建时间" width="170">
+          <template #default="{ row }">{{ formatTs(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="220" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button link type="success" @click="handleApply(row)">应用</el-button>
+            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/编辑模板 -->
+    <el-dialog v-model="editDialog" :title="editingId ? '编辑模板' : '新增模板'" width="680px">
+      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="90px">
+        <el-form-item label="模板名称" prop="name">
+          <el-input v-model="formData.name" placeholder="如:基础A记录模板" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="DNS 记录" prop="recordsText">
+          <p class="tip-text">每行一条,格式:<code>类型 名称 内容 [proxied]</code></p>
+          <pre class="tip-example">A  @  1.2.3.4  true
+A  www  1.2.3.4  true
+CNAME  mail  mail.example.com</pre>
+          <el-input v-model="formData.recordsText" type="textarea" :rows="10" placeholder="A  @  1.2.3.4  true" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">保存</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 应用模板到域名 -->
+    <el-dialog v-model="applyDialog" :title="`应用模板:${applyTemplate?.name}`" width="700px">
+      <el-form label-width="90px">
+        <el-form-item label="选择账号">
+          <el-select v-model="applyAccountId" placeholder="选择 CF 账号" @change="onApplyAccountChange" style="width:100%">
+            <el-option v-for="a in accounts" :key="a.id" :label="a.name" :value="a.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="选择域名" v-if="applyZones.length">
+          <div style="width:100%">
+            <div class="zone-actions">
+              <el-button size="small" @click="applyZoneIds = applyZones.map(z=>z.id)">全选</el-button>
+              <el-button size="small" @click="applyZoneIds = []">清空</el-button>
+              <span class="zone-count">已选 {{ applyZoneIds.length }} / {{ applyZones.length }}</span>
+            </div>
+            <el-select v-model="applyZoneIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择目标域名" style="width:100%">
+              <el-option v-for="z in applyZones" :key="z.id" :label="z.name" :value="z.id" />
+            </el-select>
+          </div>
+        </el-form-item>
+        <el-form-item label="记录预览" v-if="applyTemplate">
+          <div class="records-preview">
+            <div v-for="(r, i) in parseRecords(applyTemplate.records)" :key="i" class="record-row">
+              <el-tag size="small" type="info">{{ r.type }}</el-tag>
+              <span class="record-name">{{ r.name }}</span>
+              <span class="record-content">{{ r.content }}</span>
+              <el-tag size="small" :type="r.proxied ? 'warning' : 'info'">{{ r.proxied ? '橙云' : '灰云' }}</el-tag>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <ProgressBlock v-if="applyProgress.show" :progress="applyProgress" />
+      <template #footer>
+        <el-button @click="applyDialog = false" :disabled="applyProgress.running">取消</el-button>
+        <el-button type="primary" @click="submitApply" :loading="applyProgress.running">开始应用</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, defineComponent, h } from 'vue';
+import { ElMessage, ElMessageBox, ElProgress } from 'element-plus';
+import {
+  getDnsTemplateList, createDnsTemplate, updateDnsTemplate, deleteDnsTemplate,
+  getCfAccountList, getCfZones, batchCreateCfRecord
+} from '@/api/modules/cloudflare';
+
+const ProgressBlock = defineComponent({
+  props: { progress: Object },
+  setup(props) {
+    return () => h('div', { class: 'progress-block' }, [
+      h(ElProgress, { percentage: props.progress.pct, status: props.progress.pct === 100 ? 'success' : '' }),
+      h('p', { class: 'progress-msg' }, props.progress.msg)
+    ]);
+  }
+});
+
+const loading = ref(false);
+const templates = ref([]);
+const editDialog = ref(false);
+const editingId = ref(null);
+const formRef = ref();
+const formData = ref({ name: '', remark: '', recordsText: '' });
+const formRules = {
+  name: [{ required: true, message: '请输入模板名称' }],
+  recordsText: [{ required: true, message: '请输入 DNS 记录' }]
+};
+
+// 应用模板
+const applyDialog = ref(false);
+const applyTemplate = ref(null);
+const accounts = ref([]);
+const applyAccountId = ref(null);
+const applyZones = ref([]);
+const applyZoneIds = ref([]);
+const applyProgress = ref({ show: false, running: false, pct: 0, msg: '' });
+
+onMounted(async () => {
+  loadTemplates();
+  const res = await getCfAccountList({ pageSize: 100 });
+  accounts.value = res?.data?.list || [];
+});
+
+async function loadTemplates() {
+  loading.value = true;
+  try {
+    const res = await getDnsTemplateList({ pageSize: 100 });
+    templates.value = res?.data?.list || [];
+  } finally {
+    loading.value = false;
+  }
+}
+
+function parseRecords(jsonStr) {
+  try { return JSON.parse(jsonStr) || []; } catch { return []; }
+}
+
+function textToRecords(text) {
+  return text.trim().split('\n').filter(l => l.trim()).map(line => {
+    const parts = line.trim().split(/\s+/);
+    return { type: parts[0] || 'A', name: parts[1] || '', content: parts[2] || '', ttl: 1, proxied: parts[3] === 'true' };
+  }).filter(r => r.name && r.content);
+}
+
+function recordsToText(records) {
+  return records.map(r => `${r.type}  ${r.name}  ${r.content}${r.proxied ? '  true' : ''}`).join('\n');
+}
+
+function formatTs(ts) {
+  if (!ts) return '-';
+  return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
+}
+
+function handleAdd() {
+  editingId.value = null;
+  formData.value = { name: '', remark: '', recordsText: '' };
+  editDialog.value = true;
+}
+
+function handleEdit(row) {
+  editingId.value = row.id;
+  formData.value = { name: row.name, remark: row.remark, recordsText: recordsToText(parseRecords(row.records)) };
+  editDialog.value = true;
+}
+
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确定删除模板「${row.name}」?`, '提示', { type: 'warning' });
+  await deleteDnsTemplate(row.id);
+  ElMessage.success('已删除');
+  loadTemplates();
+}
+
+async function submitForm() {
+  await formRef.value.validate();
+  const records = textToRecords(formData.value.recordsText);
+  if (!records.length) { ElMessage.warning('没有有效的 DNS 记录'); return; }
+  const payload = { name: formData.value.name, remark: formData.value.remark, records: JSON.stringify(records) };
+  if (editingId.value) {
+    await updateDnsTemplate({ id: editingId.value, ...payload });
+    ElMessage.success('更新成功');
+  } else {
+    await createDnsTemplate(payload);
+    ElMessage.success('创建成功');
+  }
+  editDialog.value = false;
+  loadTemplates();
+}
+
+function handleApply(row) {
+  applyTemplate.value = row;
+  applyAccountId.value = null;
+  applyZones.value = [];
+  applyZoneIds.value = [];
+  applyProgress.value = { show: false, running: false, pct: 0, msg: '' };
+  applyDialog.value = true;
+}
+
+async function onApplyAccountChange() {
+  applyZones.value = [];
+  applyZoneIds.value = [];
+  if (!applyAccountId.value) return;
+  const res = await getCfZones(applyAccountId.value);
+  applyZones.value = res?.data?.result || [];
+}
+
+async function submitApply() {
+  if (!applyAccountId.value) { ElMessage.warning('请选择账号'); return; }
+  if (!applyZoneIds.value.length) { ElMessage.warning('请选择至少一个域名'); return; }
+  const records = parseRecords(applyTemplate.value.records);
+  if (!records.length) { ElMessage.warning('模板中没有有效记录'); return; }
+  const total = applyZoneIds.value.length;
+  applyProgress.value = { show: true, running: true, pct: 0, msg: '开始处理...' };
+  let ok = 0, fail = 0;
+  for (let i = 0; i < applyZoneIds.value.length; i++) {
+    const zoneId = applyZoneIds.value[i];
+    const zoneName = applyZones.value.find(z => z.id === zoneId)?.name || zoneId;
+    applyProgress.value.msg = `处理 ${zoneName} (${i + 1}/${total})`;
+    try {
+      const res = await batchCreateCfRecord({ accountId: applyAccountId.value, zoneId, records });
+      ok += res?.data?.success || 0;
+      fail += res?.data?.fail || 0;
+    } catch { fail += records.length; }
+    applyProgress.value.pct = Math.round(((i + 1) / total) * 100);
+  }
+  applyProgress.value = { show: true, running: false, pct: 100, msg: `完成:${total} 个域名,成功 ${ok} 条,失败 ${fail} 条` };
+  ElMessage.success(applyProgress.value.msg);
+}
+</script>
+
+<style scoped lang="scss">
+.page-container { padding: 20px; }
+.card-header { display: flex; align-items: center; gap: 12px; }
+.title { font-size: 16px; font-weight: 600; }
+.tip-text { color: #666; font-size: 13px; margin: 0 0 4px; }
+.tip-example { background: #f5f5f5; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 8px; }
+.zone-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+.zone-count { color: #666; font-size: 13px; }
+.records-preview { display: flex; flex-direction: column; gap: 6px; width: 100%; }
+.record-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+.record-name { font-weight: 500; min-width: 80px; }
+.record-content { color: #555; flex: 1; }
+.progress-block { margin-top: 16px; }
+.progress-msg { margin: 8px 0 0; color: #666; font-size: 13px; }
+</style>

+ 116 - 0
magic_admin_web/src/views/cloudflare/zones/index.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="page-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <el-select v-model="selectedAccountId" placeholder="选择 CF 账号" @change="loadZones" style="width:220px">
+            <el-option v-for="a in accounts" :key="a.id" :label="a.name" :value="a.id" />
+          </el-select>
+          <el-button :disabled="!selectedAccountId" @click="loadZones" :loading="loading">刷新</el-button>
+          <span v-if="zones.length" class="zone-total">共 {{ zones.length }} 个域名</span>
+        </div>
+      </template>
+
+      <el-table v-loading="loading" :data="filteredZones" border stripe>
+        <el-table-column prop="name" label="域名" min-width="180" sortable>
+          <template #default="{ row }">
+            <span class="domain-name">{{ row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
+              {{ row.status === 'active' ? '已激活' : row.status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="暂停" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.paused ? 'warning' : 'info'" size="small">
+              {{ row.paused ? '暂停' : '正常' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="套餐" width="100">
+          <template #default="{ row }">{{ row.plan?.name || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="NS 服务器" min-width="280">
+          <template #default="{ row }">
+            <div v-for="ns in row.name_servers" :key="ns" class="ns-item">{{ ns }}</div>
+          </template>
+        </el-table-column>
+        <el-table-column label="激活时间" width="180">
+          <template #default="{ row }">{{ formatDate(row.activated_on) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="goToDns(row)">DNS记录</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import { getCfAccountList, getCfZones } from '@/api/modules/cloudflare';
+
+const router = useRouter();
+const accounts = ref([]);
+const zones = ref([]);
+const selectedAccountId = ref(null);
+const loading = ref(false);
+const searchName = ref('');
+
+const filteredZones = computed(() => {
+  if (!searchName.value) return zones.value;
+  return zones.value.filter(z => z.name.includes(searchName.value));
+});
+
+onMounted(async () => {
+  const res = await getCfAccountList({ pageSize: 100 });
+  accounts.value = res?.data?.list || [];
+  if (accounts.value.length === 1) {
+    selectedAccountId.value = accounts.value[0].id;
+    loadZones();
+  }
+});
+
+async function loadZones() {
+  if (!selectedAccountId.value) return;
+  loading.value = true;
+  try {
+    const res = await getCfZones(selectedAccountId.value);
+    const result = res?.data?.result;
+    if (!Array.isArray(result)) {
+      ElMessage.error('获取域名失败');
+      return;
+    }
+    zones.value = result;
+  } catch (e) {
+    ElMessage.error('获取域名异常:' + e.message);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function formatDate(str) {
+  if (!str) return '-';
+  return new Date(str).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
+}
+
+function goToDns(zone) {
+  router.push({ name: 'cfDns', query: { accountId: selectedAccountId.value, zoneId: zone.id, zoneName: zone.name } });
+}
+</script>
+
+<style scoped lang="scss">
+.page-container { padding: 20px; }
+.card-header { display: flex; align-items: center; gap: 12px; }
+.zone-total { color: #666; font-size: 13px; }
+.domain-name { font-weight: 500; }
+.ns-item { font-size: 12px; color: #555; line-height: 1.6; }
+</style>