wilhelm wong 1 месяц назад
Родитель
Сommit
4542c85d79
4 измененных файлов с 734 добавлено и 0 удалено
  1. 5 0
      src/router/index.js
  2. 47 0
      src/services/api.js
  3. 676 0
      src/views/DomainManagementView.vue
  4. 6 0
      src/views/MainView.vue

+ 5 - 0
src/router/index.js

@@ -93,6 +93,11 @@ const router = createRouter({
           name: 'landing-domain-pool',
           component: () => import('@/views/LandingDomainPoolView.vue')
         },
+        {
+          path: 'domain-management',
+          name: 'domain-management',
+          component: () => import('@/views/DomainManagementView.vue')
+        },
       ]
     },
     {

+ 47 - 0
src/services/api.js

@@ -820,4 +820,51 @@ export const getLandingDomainPoolsByDomain = async (domain) => {
   const params = { domain }
   const response = await api.get('/landing-domain-pools/by-domain', { params })
   return response.data
+}
+
+// ==================== 域名管理相关API ====================
+
+// 创建域名
+export const createDomainManagement = async (domainData) => {
+  const response = await api.post('/domain-management', domainData)
+  return response.data
+}
+
+// 获取域名列表
+export const listDomainManagement = async (page = 0, size = 20, teamId, domainType, domain, enabled) => {
+  const params = { 
+    page: Number(page) || 0, 
+    size: Number(size) || 20 
+  }
+  if (teamId) params.teamId = teamId
+  if (domainType) params.domainType = domainType
+  if (domain) params.domain = domain
+  if (enabled !== null && enabled !== undefined) params.enabled = enabled
+
+  const response = await api.get('/domain-management', { params })
+  return response.data
+}
+
+// 获取单个域名
+export const getDomainManagement = async (id) => {
+  const response = await api.get(`/domain-management/${id}`)
+  return response.data
+}
+
+// 更新域名
+export const updateDomainManagement = async (id, domainData) => {
+  const response = await api.put(`/domain-management/${id}`, domainData)
+  return response.data
+}
+
+// 删除域名
+export const deleteDomainManagement = async (id) => {
+  const response = await api.delete(`/domain-management/${id}`)
+  return response.data
+}
+
+// 获取域名类型列表
+export const getDomainManagementTypes = async () => {
+  const response = await api.get('/domain-management/types/all')
+  return response.data
 }

+ 676 - 0
src/views/DomainManagementView.vue

@@ -0,0 +1,676 @@
+<script setup>
+import { ref, onMounted, computed, inject } from 'vue'
+import {
+  listDomainManagement,
+  createDomainManagement,
+  updateDomainManagement,
+  deleteDomainManagement,
+  getDomainManagement,
+  getDomainManagementTypes
+} from '@/services/api'
+import { useToast } from 'primevue/usetoast'
+import { useConfirm } from 'primevue/useconfirm'
+import { Form } from '@primevue/forms'
+import { zodResolver } from '@primevue/forms/resolvers/zod'
+import { useDateFormat } from '@vueuse/core'
+import { useTeamStore } from '@/stores/team'
+import { z } from 'zod'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import InputText from 'primevue/inputtext'
+import Select from 'primevue/select'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputNumber from 'primevue/inputnumber'
+import Textarea from 'primevue/textarea'
+import Message from 'primevue/message'
+import ToggleSwitch from 'primevue/toggleswitch'
+
+const toast = useToast()
+const confirm = useConfirm()
+const teamStore = useTeamStore()
+
+// 注入权限信息
+const isAdmin = inject('isAdmin')
+
+// 表格数据
+const tableData = ref({
+  data: [],
+  meta: {
+    page: 0,
+    size: 20,
+    total: 0,
+    totalPages: 0
+  }
+})
+
+// 搜索条件
+const searchForm = ref({
+  teamId: null,
+  domainType: null,
+  domain: '',
+  enabled: null
+})
+
+// 对话框相关
+const dialogVisible = ref(false)
+const isEditMode = ref(false)
+const formLoading = ref(false)
+
+// 表单数据
+const domainForm = ref({
+  id: null,
+  teamId: null,
+  domainType: 'primary',
+  domain: '',
+  remark: '',
+  enabled: true
+})
+
+// 域名类型选项
+const domainTypes = ref([
+  { label: '一级域名', value: 'primary' },
+  { label: '二级域名', value: 'secondary' }
+])
+
+// 团队选项
+const teamOptions = computed(() => {
+  return teamStore.teams.map((team) => ({
+    label: team.name,
+    value: team.id
+  }))
+})
+
+// 表单验证规则
+const formResolver = computed(() => {
+  return zodResolver(
+    z.object({
+      teamId: z.number().min(1, { message: '请选择团队' }),
+      domainType: z.enum(['primary', 'secondary'], { message: '请选择域名类型' }),
+      domain: z.string().min(1, { message: '请输入域名' }).max(255, { message: '域名长度不能超过255个字符' }),
+      remark: z.string().max(500, { message: '备注长度不能超过500个字符' }).optional().nullable(),
+      enabled: z.boolean().optional()
+    })
+  )
+})
+
+// 获取域名列表
+const fetchData = async (page = 0) => {
+  try {
+    // 确保page和size是数字类型
+    const pageNum = Number(page) || 0
+    const sizeNum = Number(tableData.value.meta.size) || 20
+    
+    const params = {
+      ...searchForm.value
+    }
+    // 移除空值,但保留page和size
+    Object.keys(params).forEach((key) => {
+      if (params[key] === '' || params[key] === null || params[key] === undefined) {
+        delete params[key]
+      }
+    })
+
+    const result = await listDomainManagement(
+      pageNum,
+      sizeNum,
+      params.teamId,
+      params.domainType,
+      params.domain,
+      params.enabled
+    )
+    tableData.value = result || {
+      data: [],
+      meta: {
+        page: 0,
+        size: 20,
+        total: 0,
+        totalPages: 0
+      }
+    }
+  } catch (error) {
+    console.error('获取域名列表失败', error)
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取域名列表失败',
+      life: 3000
+    })
+    tableData.value = {
+      data: [],
+      meta: {
+        page: 0,
+        size: 20,
+        total: 0,
+        totalPages: 0
+      }
+    }
+  }
+}
+
+// 分页变化
+const handlePageChange = (event) => {
+  tableData.value.meta.page = event.page
+  tableData.value.meta.size = event.rows
+  fetchData(event.page)
+}
+
+// 刷新数据
+const handleRefresh = () => {
+  searchForm.value = {
+    teamId: null,
+    domainType: null,
+    domain: '',
+    enabled: null
+  }
+  tableData.value.meta.page = 0
+  fetchData(0)
+}
+
+// 格式化日期
+const formatDate = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+// 获取域名类型标签
+const getDomainTypeLabel = (type) => {
+  const option = domainTypes.value.find((opt) => opt.value === type)
+  return option ? option.label : type
+}
+
+// 获取团队名称
+const getTeamName = (teamId) => {
+  if (!teamId) return '-'
+  const team = teamStore.teams.find((t) => t.id === teamId)
+  return team ? team.name : '-'
+}
+
+// 打开新建对话框
+const openNewDialog = () => {
+  domainForm.value = {
+    id: null,
+    teamId: null,
+    domainType: 'primary',
+    domain: '',
+    remark: '',
+    enabled: true
+  }
+  isEditMode.value = false
+  dialogVisible.value = true
+}
+
+// 打开编辑对话框
+const openEditDialog = async (domain) => {
+  try {
+    const detail = await getDomainManagement(domain.id)
+    domainForm.value = {
+      id: detail.id,
+      teamId: detail.teamId,
+      domainType: detail.domainType,
+      domain: detail.domain,
+      remark: detail.remark || '',
+      enabled: detail.enabled !== undefined ? detail.enabled : true
+    }
+    isEditMode.value = true
+    dialogVisible.value = true
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取域名详情失败',
+      life: 3000
+    })
+  }
+}
+
+// 保存域名
+const saveDomain = async ({ valid, values }) => {
+  if (!valid) return
+
+  formLoading.value = true
+  try {
+    const submitData = {
+      teamId: values.teamId,
+      domainType: values.domainType,
+      domain: values.domain,
+      enabled: values.enabled !== undefined ? values.enabled : true
+    }
+
+    if (values.remark) {
+      submitData.remark = values.remark
+    }
+
+    if (isEditMode.value) {
+      await updateDomainManagement(domainForm.value.id, submitData)
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '域名更新成功',
+        life: 3000
+      })
+    } else {
+      await createDomainManagement(submitData)
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '域名创建成功',
+        life: 3000
+      })
+    }
+
+    dialogVisible.value = false
+    fetchData(tableData.value.meta.page)
+  } catch (error) {
+    const errorMsg = error.message || (isEditMode.value ? '更新域名失败' : '创建域名失败')
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMsg,
+      life: 3000
+    })
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 删除域名
+const handleDelete = (domain) => {
+  confirm.require({
+    message: `确定要删除域名 "${domain.domain}" 吗?`,
+    header: '删除确认',
+    icon: 'pi pi-exclamation-triangle',
+    rejectLabel: '取消',
+    rejectProps: {
+      label: '取消',
+      severity: 'secondary'
+    },
+    acceptLabel: '删除',
+    acceptProps: {
+      label: '删除',
+      severity: 'danger'
+    },
+    accept: async () => {
+      try {
+        await deleteDomainManagement(domain.id)
+        toast.add({
+          severity: 'success',
+          summary: '成功',
+          detail: '删除域名成功',
+          life: 3000
+        })
+        fetchData(tableData.value.meta.page)
+      } catch (error) {
+        toast.add({
+          severity: 'error',
+          summary: '错误',
+          detail: error.message || '删除域名失败',
+          life: 3000
+        })
+      }
+    }
+  })
+}
+
+// 初始化
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <!-- 权限检查 -->
+    <div v-if="!isAdmin" class="text-center py-8">
+      <p class="text-gray-500">您没有权限访问域名管理</p>
+    </div>
+
+    <!-- 主要内容 -->
+    <div v-else>
+      <DataTable
+        :value="tableData.data"
+        :paginator="true"
+        paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown JumpToPageInput"
+        currentPageReportTemplate="{totalRecords} 条记录 "
+        :rows="tableData.meta.size"
+        :rowsPerPageOptions="[10, 20, 50, 100]"
+        :totalRecords="tableData.meta.total"
+        @page="handlePageChange"
+        lazy
+        scrollable
+        stripedRows
+        showGridlines
+      >
+        <template #header>
+          <div class="search-toolbar">
+            <div class="toolbar-left">
+              <Button icon="pi pi-refresh" @click="fetchData" size="small" label="刷新" />
+              <Button
+                icon="pi pi-plus"
+                @click="openNewDialog"
+                label="新增域名"
+                severity="success"
+                size="small"
+              />
+            </div>
+            <div class="toolbar-right">
+              <div class="search-group">
+                <Select
+                  v-model="searchForm.teamId"
+                  :options="teamOptions"
+                  optionLabel="label"
+                  optionValue="value"
+                  placeholder="选择团队"
+                  size="small"
+                  class="search-field"
+                  clearable
+                />
+                <Select
+                  v-model="searchForm.domainType"
+                  :options="domainTypes"
+                  optionLabel="label"
+                  optionValue="value"
+                  placeholder="域名类型"
+                  size="small"
+                  class="search-field"
+                  clearable
+                />
+                <InputText
+                  v-model="searchForm.domain"
+                  placeholder="搜索域名"
+                  size="small"
+                  class="search-field"
+                  @keyup.enter="fetchData"
+                />
+                <Select
+                  v-model="searchForm.enabled"
+                  :options="[
+                    { label: '可用', value: true },
+                    { label: '不可用', value: false }
+                  ]"
+                  optionLabel="label"
+                  optionValue="value"
+                  placeholder="状态"
+                  size="small"
+                  class="search-field"
+                  clearable
+                />
+              </div>
+              <div class="action-group">
+                <Button icon="pi pi-search" @click="fetchData" label="搜索" size="small" severity="secondary" />
+                <Button icon="pi pi-refresh" @click="handleRefresh" label="重置" size="small" />
+              </div>
+            </div>
+          </div>
+        </template>
+
+        <Column field="id" header="ID" style="min-width: 80px" headerClass="font-bold"></Column>
+        <Column field="teamId" header="团队" style="min-width: 120px" headerClass="font-bold">
+          <template #body="slotProps">
+            <span class="team-name-text font-medium">
+              {{ getTeamName(slotProps.data.teamId) }}
+            </span>
+          </template>
+        </Column>
+        <Column field="domainType" header="域名类型" style="min-width: 100px" headerClass="font-bold">
+          <template #body="slotProps">
+            <span class="px-2 py-1 rounded-md text-sm">
+              {{ getDomainTypeLabel(slotProps.data.domainType) }}
+            </span>
+          </template>
+        </Column>
+        <Column field="domain" header="域名" style="min-width: 200px" headerClass="font-bold"></Column>
+        <Column field="remark" header="备注" style="min-width: 150px" headerClass="font-bold">
+          <template #body="slotProps">
+            {{ slotProps.data.remark || '-' }}
+          </template>
+        </Column>
+        <Column field="enabled" header="状态" style="min-width: 80px" headerClass="font-bold">
+          <template #body="slotProps">
+            <span :class="slotProps.data.enabled ? 'text-green-600' : 'text-red-600'" class="font-medium">
+              {{ slotProps.data.enabled ? '可用' : '不可用' }}
+            </span>
+          </template>
+        </Column>
+        <Column field="createdAt" header="创建时间" style="min-width: 180px" headerClass="font-bold">
+          <template #body="slotProps">
+            {{ formatDate(slotProps.data.createdAt) }}
+          </template>
+        </Column>
+        <Column field="updatedAt" header="更新时间" style="min-width: 180px" headerClass="font-bold">
+          <template #body="slotProps">
+            {{ formatDate(slotProps.data.updatedAt) }}
+          </template>
+        </Column>
+        <Column header="操作" style="min-width: 150px" headerClass="font-bold" align="center">
+          <template #body="slotProps">
+            <div class="flex gap-2 justify-center">
+              <Button
+                icon="pi pi-pencil"
+                severity="info"
+                size="small"
+                text
+                rounded
+                aria-label="编辑"
+                @click="openEditDialog(slotProps.data)"
+              />
+              <Button
+                icon="pi pi-trash"
+                severity="danger"
+                size="small"
+                text
+                rounded
+                aria-label="删除"
+                @click="handleDelete(slotProps.data)"
+              />
+            </div>
+          </template>
+        </Column>
+      </DataTable>
+
+      <!-- 域名表单对话框 -->
+      <Dialog
+        v-model:visible="dialogVisible"
+        :modal="true"
+        :header="isEditMode ? '编辑域名' : '创建域名'"
+        :style="{ width: '500px' }"
+        position="center"
+      >
+        <Form
+          v-slot="$form"
+          :resolver="formResolver"
+          :initialValues="domainForm"
+          @submit="saveDomain"
+          class="p-fluid"
+        >
+          <div class="field mt-4">
+            <FloatLabel variant="on">
+              <Select
+                id="teamId"
+                name="teamId"
+                v-model="domainForm.teamId"
+                :options="teamOptions"
+                optionLabel="label"
+                optionValue="value"
+                fluid
+              />
+              <label for="teamId">团队 <span class="text-red-500">*</span></label>
+            </FloatLabel>
+            <Message v-if="$form.teamId?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.teamId.error?.message }}
+            </Message>
+          </div>
+
+          <div class="field mt-4">
+            <FloatLabel variant="on">
+              <Select
+                id="domainType"
+                name="domainType"
+                v-model="domainForm.domainType"
+                :options="domainTypes"
+                optionLabel="label"
+                optionValue="value"
+                fluid
+              />
+              <label for="domainType">域名类型 <span class="text-red-500">*</span></label>
+            </FloatLabel>
+            <Message v-if="$form.domainType?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.domainType.error?.message }}
+            </Message>
+          </div>
+
+          <div class="field mt-4">
+            <FloatLabel variant="on">
+              <IconField>
+                <InputIcon class="pi pi-globe" />
+                <InputText id="domain" name="domain" v-model="domainForm.domain" autocomplete="off" fluid />
+              </IconField>
+              <label for="domain">域名 <span class="text-red-500">*</span></label>
+            </FloatLabel>
+            <Message v-if="$form.domain?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.domain.error?.message }}
+            </Message>
+          </div>
+
+          <div class="field mt-4">
+            <FloatLabel variant="on">
+              <IconField>
+                <InputIcon class="pi pi-comment" />
+                <Textarea
+                  id="remark"
+                  name="remark"
+                  v-model="domainForm.remark"
+                  rows="3"
+                  autocomplete="off"
+                  fluid
+                />
+              </IconField>
+              <label for="remark">备注</label>
+            </FloatLabel>
+            <Message v-if="$form.remark?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.remark.error?.message }}
+            </Message>
+          </div>
+
+          <div class="field mt-4">
+            <div class="flex items-center gap-3">
+              <ToggleSwitch id="enabled" name="enabled" v-model="domainForm.enabled" />
+              <label for="enabled" class="font-medium">是否可用</label>
+            </div>
+          </div>
+
+          <div class="flex justify-end gap-2 mt-6">
+            <Button
+              label="取消"
+              severity="secondary"
+              type="button"
+              @click="dialogVisible = false"
+              :disabled="formLoading"
+            />
+            <Button label="保存" type="submit" :loading="formLoading" />
+          </div>
+        </Form>
+      </Dialog>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* 搜索工具栏样式 */
+.search-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
+  padding: 16px;
+  background: #f8fafc;
+  border-radius: 8px;
+  border: 1px solid #e2e8f0;
+}
+
+.toolbar-left {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.toolbar-left .p-button {
+  font-size: 13px;
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+.toolbar-right {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.search-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.search-field {
+  width: 150px;
+  font-size: 13px;
+}
+
+.action-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.action-group .p-button {
+  font-size: 13px;
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+.team-name-text {
+  color: #7c3aed;
+  font-weight: 500;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .search-toolbar {
+    flex-direction: column;
+    gap: 12px;
+    align-items: stretch;
+    padding: 12px;
+  }
+
+  .toolbar-left {
+    justify-content: center;
+    gap: 8px;
+  }
+
+  .toolbar-right {
+    flex-direction: column;
+    gap: 12px;
+    align-items: stretch;
+  }
+
+  .search-group {
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .search-field {
+    width: 100%;
+  }
+}
+</style>
+

+ 6 - 0
src/views/MainView.vue

@@ -113,6 +113,12 @@ const allNavItems = [
     icon: 'pi pi-fw pi-cog',
     name: 'sys-config',
     roles: ['admin']
+  },
+  {
+    label: '域名管理',
+    icon: 'pi pi-fw pi-globe',
+    name: 'domain-management',
+    roles: ['admin']
   }
 ]