소스 검색

feat: add Cloudflare DNS management frontend

- api/modules/cloudflare.js:账号 & DNS API 封装
- views/cloudflare/account/index.vue:账号管理页(增删改查)
- views/cloudflare/dns/index.vue:DNS 管理页(Zone 切换、记录增删改、批量创建、橙云批量切换)
- staticRouter.js:注册 Cloudflare 菜单路由

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
urban 1 개월 전
부모
커밋
2ce194b576

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

@@ -0,0 +1,22 @@
+import http from "@/api";
+
+const ACCOUNT = "/admin/api/app/cf_account";
+const DNS = "/admin/api/app/cf_dns";
+
+// ==================== 账号管理 ====================
+
+export const getCfAccountList = params => http.get(`${ACCOUNT}/find`, params);
+export const getCfAccount = id => http.get(`${ACCOUNT}/get`, { id });
+export const createCfAccount = data => http.post(`${ACCOUNT}/create`, data);
+export const updateCfAccount = data => http.post(`${ACCOUNT}/update`, data);
+export const deleteCfAccount = id => http.get(`${ACCOUNT}/delete`, { id });
+
+// ==================== DNS 管理 ====================
+
+export const getCfZones = accountId => http.get(`${DNS}/zones`, { account_id: accountId });
+export const getCfRecords = params => http.get(`${DNS}/records`, params);
+export const createCfRecord = data => http.post(`${DNS}/record/create`, data);
+export const batchCreateCfRecord = data => http.post(`${DNS}/record/batch_create`, data);
+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);

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

@@ -73,6 +73,34 @@ export const staticRouter = [
           }
         ]
       },
+      // ==================== Cloudflare 管理 ====================
+      {
+        path: "/cloudflare",
+        name: "cloudflare",
+        meta: {
+          icon: "Monitor",
+          isAffix: false,
+          isFull: false,
+          isHide: false,
+          isKeepAlive: true,
+          isLink: false,
+          title: "Cloudflare"
+        },
+        children: [
+          {
+            path: "account",
+            name: "cfAccount",
+            component: () => import("@/views/cloudflare/account/index.vue"),
+            meta: { title: "账号管理", icon: "Key", isKeepAlive: true }
+          },
+          {
+            path: "dns",
+            name: "cfDns",
+            component: () => import("@/views/cloudflare/dns/index.vue"),
+            meta: { title: "DNS 管理", icon: "Connection", isKeepAlive: true }
+          }
+        ]
+      },
       // ==================== 系统配置 ====================
       {
         path: "/system",

+ 119 - 0
magic_admin_web/src/views/cloudflare/account/index.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="page-container">
+    <ProTable
+      ref="proTableRef"
+      :columns="columns"
+      :request-api="getCfAccountList"
+      :search-columns="searchColumns"
+      :toolbar-buttons="toolbarButtons"
+    >
+      <template #status="{ row }">
+        <el-tag :type="row.status === 1 ? 'success' : 'danger'">
+          {{ row.status === 1 ? '正常' : '禁用' }}
+        </el-tag>
+      </template>
+      <template #apiToken="{ row }">
+        <span>{{ row.apiToken ? row.apiToken.slice(0, 8) + '••••••••' : '-' }}</span>
+      </template>
+      <template #actions="{ row }">
+        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+      </template>
+    </ProTable>
+
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑账号' : '新增账号'" width="520px">
+      <el-form ref="formRef" :model="formData" :rules="rules" label-width="110px">
+        <el-form-item label="账号名称" prop="name">
+          <el-input v-model="formData.name" placeholder="便于识别的名称" />
+        </el-form-item>
+        <el-form-item label="CF 邮箱">
+          <el-input v-model="formData.email" placeholder="Cloudflare 账号邮箱(选填)" />
+        </el-form-item>
+        <el-form-item label="API Token" prop="apiToken">
+          <el-input v-model="formData.apiToken" type="password" show-password placeholder="Cloudflare API Token" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="formData.remark" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item v-if="isEdit" label="状态">
+          <el-select v-model="formData.status">
+            <el-option label="正常" :value="1" />
+            <el-option label="禁用" :value="2" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import ProTable from '@/components/ProTable/index.vue';
+import { getCfAccountList, createCfAccount, updateCfAccount, deleteCfAccount } from '@/api/modules/cloudflare';
+
+const proTableRef = ref();
+const formRef = ref();
+const dialogVisible = ref(false);
+const isEdit = ref(false);
+
+const formData = reactive({ id: null, name: '', email: '', apiToken: '', remark: '', status: 1 });
+
+const rules = {
+  name: [{ required: true, message: '请输入账号名称', trigger: 'blur' }],
+  apiToken: [{ required: true, message: '请输入 API Token', trigger: 'blur' }]
+};
+
+const columns = [
+  { prop: 'id', label: 'ID', width: 70 },
+  { prop: 'name', label: '账号名称', minWidth: 150 },
+  { prop: 'email', label: '邮箱', minWidth: 180 },
+  { prop: 'apiToken', label: 'API Token', minWidth: 160, slot: 'apiToken' },
+  { prop: 'status', label: '状态', width: 90, slot: 'status' },
+  { prop: 'remark', label: '备注', minWidth: 150 },
+  { prop: 'actions', label: '操作', width: 130, fixed: 'right', slot: 'actions' }
+];
+
+const searchColumns = [
+  { prop: 'name', label: '账号名称', type: 'input' },
+  { prop: 'status', label: '状态', type: 'select', options: [{ label: '正常', value: 1 }, { label: '禁用', value: 2 }] }
+];
+
+const toolbarButtons = [{ label: '新增账号', type: 'primary', icon: 'Plus', onClick: handleAdd }];
+
+function handleAdd() {
+  isEdit.value = false;
+  Object.assign(formData, { id: null, name: '', email: '', apiToken: '', remark: '', status: 1 });
+  dialogVisible.value = true;
+}
+
+function handleEdit(row) {
+  isEdit.value = true;
+  Object.assign(formData, { ...row, apiToken: '' });
+  dialogVisible.value = true;
+}
+
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确定删除账号「${row.name}」?`, '提示', { type: 'warning' });
+  await deleteCfAccount(row.id);
+  ElMessage.success('删除成功');
+  proTableRef.value.refresh();
+}
+
+async function handleSubmit() {
+  await formRef.value.validate();
+  const api = isEdit.value ? updateCfAccount : createCfAccount;
+  await api(formData);
+  ElMessage.success(isEdit.value ? '更新成功' : '创建成功');
+  dialogVisible.value = false;
+  proTableRef.value.refresh();
+}
+</script>
+
+<style scoped lang="scss">
+.page-container { padding: 20px; }
+</style>

+ 245 - 0
magic_admin_web/src/views/cloudflare/dns/index.vue

@@ -0,0 +1,245 @@
+<template>
+  <div class="page-container">
+    <!-- 顶部选择器 -->
+    <el-card class="selector-card">
+      <el-row :gutter="16" align="middle">
+        <el-col :span="6">
+          <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-option v-for="z in zones" :key="z.id" :label="z.name" :value="z.id" />
+          </el-select>
+        </el-col>
+        <el-col :span="10">
+          <el-button type="primary" :disabled="!selectedZoneId" @click="showAddDialog">新增记录</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-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-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 />
+        <el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="ttl" label="TTL" width="80">
+          <template #default="{ row }">{{ row.ttl === 1 ? 'Auto' : row.ttl }}</template>
+        </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>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <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">
+          <el-select v-model="recordForm.type" 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="名称" 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-select>
+        </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>
+        <el-button type="primary" @click="submitRecord">确定</el-button>
+      </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"
+      />
+      <template #footer>
+        <el-button @click="batchDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitBatch">批量提交</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import {
+  getCfAccountList, getCfZones, getCfRecords,
+  createCfRecord, batchCreateCfRecord, updateCfRecord,
+  deleteCfRecord, toggleCfProxy
+} from '@/api/modules/cloudflare';
+
+const accounts = ref([]);
+const zones = ref([]);
+const records = ref([]);
+const selectedAccountId = ref(null);
+const selectedZoneId = ref(null);
+const selectedIds = ref([]);
+const loading = ref(false);
+
+const recordDialog = ref(false);
+const batchDialog = ref(false);
+const editRecord = ref(null);
+const recordFormRef = ref();
+const batchText = ref('');
+
+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: '请选择类型' }],
+  name: [{ required: true, message: '请输入名称' }],
+  content: [{ required: true, message: '请输入内容' }]
+};
+
+onMounted(async () => {
+  const res = await getCfAccountList({ pageSize: 100 });
+  accounts.value = res?.data?.list || [];
+});
+
+async function onAccountChange() {
+  selectedZoneId.value = null;
+  zones.value = [];
+  records.value = [];
+  if (!selectedAccountId.value) return;
+  const res = await getCfZones(selectedAccountId.value);
+  zones.value = res?.data?.result || [];
+}
+
+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;
+}
+
+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 handleEdit(row) {
+  editRecord.value = row;
+  recordForm.value = { type: row.type, name: row.name, content: row.content, ttl: row.ttl, proxied: row.proxied };
+  recordDialog.value = true;
+}
+
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确定删除记录 ${row.name}?`, '提示', { type: 'warning' });
+  await deleteCfRecord({ account_id: selectedAccountId.value, zone_id: selectedZoneId.value, record_id: row.id });
+  ElMessage.success('删除成功');
+  loadRecords();
+}
+
+async function submitRecord() {
+  await recordFormRef.value.validate();
+  if (editRecord.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 });
+    ElMessage.success('创建成功');
+  }
+  recordDialog.value = false;
+  loadRecords();
+}
+
+async function submitBatch() {
+  const lines = batchText.value.trim().split('\n').filter(l => l.trim());
+  const recordsList = lines.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);
+
+  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}`);
+  batchDialog.value = false;
+  batchText.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}`);
+  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; }
+</style>