Files
tiantian-system/app/pages/developer/resources/domains.vue
2026-04-08 17:10:58 +08:00

388 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dev-page">
<!-- 工具栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<h3 class="page-title">域名管理</h3>
<a-tag color="blue">{{ list.length }} </a-tag>
<a-select v-model:value="selectedAppId" placeholder="全部应用" allow-clear style="width: 180px" @change="loadList">
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">{{ app.productName }}</a-select-option>
</a-select>
</div>
<div class="toolbar-right">
<a-select v-model:value="filterIcp" placeholder="备案状态" style="width: 120px" allow-clear @change="loadList">
<a-select-option :value="true">已备案</a-select-option>
<a-select-option :value="false">未备案</a-select-option>
</a-select>
<a-select v-model:value="filterSsl" placeholder="SSL 状态" style="width: 120px" allow-clear @change="loadList">
<a-select-option :value="true">已绑定</a-select-option>
<a-select-option :value="false">未绑定</a-select-option>
</a-select>
<a-input-search v-model:value="searchText" placeholder="搜索域名 / 备案号" style="width: 200px" allow-clear @search="loadList" />
<PermissionGuard :app-id="selectedAppId" permission="canEditResource" :show-tip="true">
<a-button type="primary" @click="showAdd = true">
<template #icon><PlusOutlined /></template>添加域名
</a-button>
</PermissionGuard>
</div>
</div>
<!-- 通知栏 -->
<div class="notice-bar">
<InfoCircleOutlined class="notice-icon" />
<span>在此管理应用所使用的域名支持关联 SSL 证书和绑定具体应用</span>
</div>
<!-- 域名卡片列表 -->
<div v-if="loading" class="card-loading"><a-spin size="large" /></div>
<div v-else-if="list.length === 0" class="card-empty">
<a-empty description="暂无域名,点击右上角添加">
<a-button type="primary" @click="showAdd = true"><template #icon><PlusOutlined /></template>添加域名</a-button>
</a-empty>
</div>
<div v-else class="domain-grid">
<div v-for="item in list" :key="item.resourceId" class="domain-card" :class="{ 'card-expiring': isNearExpiry(item.expireAt) }">
<!-- 卡片头部 -->
<div class="card-header">
<div class="card-header-left">
<div class="domain-name" @click="copyText(item.domain!)">
<GlobalOutlined />
<span class="domain-text">{{ item.domain }}</span>
<CopyOutlined class="copy-icon" />
</div>
<div class="domain-tags">
<a-tag :color="item.icp ? 'green' : 'orange'" size="small">{{ item.icp ? '已备案' : '未备案' }}</a-tag>
<a-tag :color="item.sslBound ? 'blue' : 'default'" size="small">{{ item.sslBound ? 'SSL 已绑定' : 'SSL 未绑定' }}</a-tag>
<a-tag v-if="isNearExpiry(item.expireAt)" color="red" size="small">即将到期</a-tag>
</div>
</div>
<a-dropdown :trigger="['click']">
<a-button type="text" size="small"><template #icon><EllipsisOutlined /></template></a-button>
<template #overlay>
<a-menu @click="({ key }: any) => handleMenuAction(key, item)">
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="edit"><template #icon><EditOutlined /></template>编辑</a-menu-item>
<a-menu-item v-if="!item.sslBound" key="bindSsl"><template #icon><SafetyOutlined /></template>绑定 SSL</a-menu-item>
<a-menu-item v-if="item.sslBound" key="unbindSsl"><template #icon><DisconnectOutlined /></template>解绑 SSL</a-menu-item>
<a-menu-divider v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" />
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="delete" danger><template #icon><DeleteOutlined /></template>移除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 卡片信息区 -->
<div class="card-info">
<div class="info-row"><span class="info-label">注册商</span><span class="info-value">{{ item.registrar || '-' }}</span></div>
<div class="info-row">
<span class="info-label">备案号</span>
<span class="info-value icp-value" @click="item.icpNo && copyText(item.icpNo)">
{{ item.icpNo || '未备案' }}<CopyOutlined v-if="item.icpNo" class="copy-icon-small" />
</span>
</div>
<div class="info-row">
<span class="info-label">SSL 证书</span>
<span class="info-value">
<template v-if="item.sslBound && item.sslCertName"><SafetyOutlined style="color: #52c41a; margin-right: 4px;" />{{ item.sslCertName }}</template>
<span v-else class="text-muted">未绑定</span>
</span>
</div>
<div class="info-row"><span class="info-label">关联应用</span><span class="info-value">{{ item.appName || '未关联' }}</span></div>
<div class="info-row">
<span class="info-label">到期时间</span>
<span class="info-value" :class="{ 'text-warning': isNearExpiry(item.expireAt), 'text-danger': isExpired(item.expireAt) }">{{ item.expireAt || '-' }}</span>
</div>
</div>
<!-- 快捷操作区 -->
<div class="card-actions">
<a-button v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" size="small" @click="handleEdit(item)"><template #icon><EditOutlined /></template>编辑</a-button>
<a-button v-if="!item.sslBound" size="small" @click="handleBindSsl(item)"><template #icon><SafetyOutlined /></template>绑定 SSL</a-button>
<a-button v-if="item.sslBound" size="small" @click="handleViewSsl(item)"><template #icon><EyeOutlined /></template>查看证书</a-button>
<span v-if="!item.isOwner && !getAppPermission(item.appId)?.canEditResource" style="font-size: 11px; color: #faad14; display:flex; align-items:center; gap:4px;">
<LockOutlined /> 协作者
</span>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="pagination-wrapper">
<a-pagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="['12', '24', '48']" show-size-changer show-total @change="handlePageChange" />
</div>
<!-- 添加/编辑弹窗 -->
<a-modal v-model:open="showAdd" :title="editRecord ? '编辑域名' : '添加域名'" ok-text="保存" cancel-text="取消" :confirm-loading="saveLoading" width="560px" @ok="handleSave" @cancel="resetForm">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
<div class="form-section-header"><GlobalOutlined /><span>基本信息</span></div>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="域名" name="domain">
<a-input v-model:value="form.domain" placeholder="如example.com" :disabled="!!editRecord" @blur="handleDomainBlur" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="到期时间" name="expireAt">
<a-date-picker v-model:value="form.expireAt" value-format="YYYY-MM-DD" style="width: 100%" placeholder="可选" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="注册商" name="registrar">
<a-input v-model:value="form.registrar" placeholder="如:腾讯云、阿里云" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="关联应用" name="appId">
<a-select v-model:value="form.appId" placeholder="可选" allow-clear>
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">{{ app.productName }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<div class="form-section-header"><SafetyCertificateOutlined /><span>ICP 备案</span></div>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="备案状态" name="icp">
<a-radio-group v-model:value="form.icp">
<a-radio :value="true">已备案</a-radio>
<a-radio :value="false">未备案</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="16">
<a-form-item label="ICP 备案号" name="icpNo">
<a-input v-model:value="form.icpNo" placeholder="如粤ICP备XXXXXXXX号" />
</a-form-item>
</a-col>
</a-row>
<div class="form-section-header"><SafetyOutlined /><span>SSL 证书</span><span class="form-section-hint">可选绑定已有证书</span></div>
<a-form-item label="选择证书" name="sslResourceId">
<a-select v-model:value="form.sslResourceId" placeholder="选择要绑定的 SSL 证书" allow-clear @change="handleSslChange">
<a-select-option v-for="cert in sslOptions" :key="cert.resourceId" :value="cert.resourceId">
<div style="display: flex; align-items: center; gap: 8px;">
<span>{{ cert.name }}</span>
<a-tag size="small" :color="certTypeColor[cert.certType!]">{{ cert.certType }}</a-tag>
<span style="color: #999; font-size: 12px;">{{ cert.domain }}</span>
</div>
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="备注" name="remark" style="margin-bottom: 0">
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
</a-form-item>
</a-form>
</a-modal>
<!-- 绑定 SSL 弹窗 -->
<a-modal v-model:open="showBindSsl" title="绑定 SSL 证书" ok-text="绑定" cancel-text="取消" @ok="confirmBindSsl" @cancel="cancelBindSsl">
<a-form layout="vertical">
<a-form-item label="域名"><a-input :value="currentDomain?.domain" disabled /></a-form-item>
<a-form-item label="选择 SSL 证书">
<a-select v-model:value="bindSslForm.sslResourceId" placeholder="选择要绑定的 SSL 证书">
<a-select-option v-for="cert in sslOptions" :key="cert.resourceId" :value="cert.resourceId">
<div style="display: flex; align-items: center; gap: 8px;">
<span>{{ cert.name }}</span>
<a-tag size="small" :color="certTypeColor[cert.certType!]">{{ cert.certType }}</a-tag>
<span style="color: #999; font-size: 12px;">{{ cert.domain }}</span>
</div>
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 查看 SSL 证书弹窗 -->
<a-modal v-model:open="showViewSsl" title="SSL 证书信息" :footer="null" @cancel="showViewSsl = false">
<div v-if="currentSslCert" class="ssl-info">
<div class="ssl-info-item"><span class="ssl-info-label">证书名称</span><span class="ssl-info-value">{{ currentSslCert.name }}</span></div>
<div class="ssl-info-item"><span class="ssl-info-label">绑定域名</span><span class="ssl-info-value">{{ currentSslCert.domain }}</span></div>
<div class="ssl-info-item"><span class="ssl-info-label">证书类型</span><span class="ssl-info-value"><a-tag :color="certTypeColor[currentSslCert.certType!]">{{ currentSslCert.certType }}</a-tag></span></div>
<div class="ssl-info-item"><span class="ssl-info-label">颁发机构</span><span class="ssl-info-value">{{ currentSslCert.issuer || '-' }}</span></div>
<div class="ssl-info-item"><span class="ssl-info-label">到期时间</span><span class="ssl-info-value" :class="{ 'text-warning': isNearExpiry(currentSslCert.expireAt) }">{{ currentSslCert.expireAt }}</span></div>
<div class="ssl-info-item"><span class="ssl-info-label">加密算法</span><span class="ssl-info-value">{{ currentSslCert.algorithm || '-' }} {{ currentSslCert.keyBits ? `(${currentSslCert.keyBits}位)` : '' }}</span></div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { PlusOutlined, EditOutlined, DeleteOutlined, EllipsisOutlined, GlobalOutlined, CopyOutlined, InfoCircleOutlined, SafetyOutlined, SafetyCertificateOutlined, DisconnectOutlined, EyeOutlined, LockOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import dayjs from 'dayjs'
import { pageAppResource, addAppResource, updateAppResource, removeAppResource } from '@/api/app/appResource'
import { getMyAccessibleApps } from '@/api/app/appProduct'
import { useAppPermission } from '@/composables/useAppPermission'
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
import type { AppResource, AppResourceParam } from '@/api/app/appResource/model'
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
definePageMeta({ layout: 'developer' })
useHead({ title: '域名管理 - 开发者中心' })
const DOMAIN_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
const loading = ref(false)
const saveLoading = ref(false)
const showAdd = ref(false)
const showBindSsl = ref(false)
const showViewSsl = ref(false)
const searchText = ref('')
const filterIcp = ref<boolean | undefined>(undefined)
const filterSsl = ref<boolean | undefined>(undefined)
const editRecord = ref<AppResource | null>(null)
const currentDomain = ref<AppResource | null>(null)
const currentSslCert = ref<AppResource | null>(null)
const formRef = ref()
const selectedAppId = ref<number | undefined>(undefined)
const { getAppPermission } = useAppPermission()
const form = reactive({ domain: '', registrar: '', icp: false, icpNo: '', sslResourceId: undefined as number | undefined, appId: undefined as number | undefined, expireAt: null as any, remark: '' })
const bindSslForm = reactive({ sslResourceId: undefined as number | undefined })
const rules = {
domain: [
{ required: true, message: '请输入域名' },
{ validator: async (_rule: any, value: string) => { if (!value) return Promise.resolve(); const domain = value.trim().toLowerCase(); const testDomain = domain.startsWith('*.') ? domain.slice(2) : domain; if (DOMAIN_REGEX.test(testDomain)) return Promise.resolve(); return Promise.reject(new Error('请输入合法的域名')) }, trigger: ['blur', 'change'] },
],
}
const certTypeColor: Record<string, string> = { DV: 'blue', OV: 'purple', EV: 'gold' }
const pagination = reactive({ current: 1, pageSize: 12, total: 0 })
const list = ref<AppResource[]>([])
const appOptions = ref<any[]>([])
const sslOptions = ref<AppResource[]>([])
function isNearExpiry(dateStr?: string): boolean { if (!dateStr) return false; return dayjs(dateStr).diff(dayjs(), 'day') <= 30 && dayjs(dateStr).diff(dayjs(), 'day') >= 0 }
function isExpired(dateStr?: string): boolean { if (!dateStr) return false; return dayjs(dateStr).diff(dayjs(), 'day') < 0 }
async function copyText(text: string) { try { await navigator.clipboard.writeText(text); message.success('已复制: ' + text) } catch { message.error('复制失败') } }
function handleDomainBlur() { if (form.domain && !form.icpNo) { } }
function handleSslChange(value: number) { if (value) form.icp = true }
function handleMenuAction(key: string, item: AppResource) {
if (key === 'edit') handleEdit(item)
else if (key === 'bindSsl') handleBindSsl(item)
else if (key === 'unbindSsl') handleUnbindSsl(item)
else if (key === 'delete') {
if (!item.isOwner) { message.warning('只有域名创建者才能删除'); return }
Modal.confirm({ title: '确定要移除此域名?', content: `将移除「${item.domain}`, okText: '确定移除', okType: 'danger', cancelText: '取消', onOk: () => handleDelete(item.resourceId!) })
}
}
async function loadAppOptions() { try { appOptions.value = await getMyAccessibleApps() } catch { appOptions.value = [] } }
async function loadSslOptions() { try { const result = await pageAppResource({ resourceType: 'ssl', page: 1, limit: 200 }); sslOptions.value = result?.list ?? [] } catch { sslOptions.value = [] } }
async function loadList() {
loading.value = true
try {
const params: AppResourceParam = { resourceType: 'domain', keywords: searchText.value || undefined, appId: selectedAppId.value, page: pagination.current, limit: pagination.pageSize }
if (filterIcp.value !== undefined) (params as any).icp = filterIcp.value
if (filterSsl.value !== undefined) (params as any).sslBound = filterSsl.value
const result = await pageAppResource(params)
list.value = enrichResourcesWithPermission(result?.list ?? [])
pagination.total = result?.count ?? 0
} catch (e: any) { message.error(e.message || '加载失败') } finally { loading.value = false }
}
function handlePageChange(page: number, pageSize: number) { pagination.current = page; pagination.pageSize = pageSize; loadList() }
function handleEdit(record: AppResource) {
if (!record.isOwner) {
message.warning('只有域名创建者才能编辑')
return
}
editRecord.value = record
Object.assign(form, { domain: record.domain, registrar: record.registrar, icp: record.icp ?? false, icpNo: record.icpNo, sslResourceId: record.sslResourceId, appId: record.appId ? Number(record.appId) : undefined, expireAt: record.expireAt || null, remark: record.remark, resourceId: record.resourceId })
showAdd.value = true
}
async function handleDelete(resourceId: number) { try { await removeAppResource(resourceId); message.success('已移除'); loadList() } catch (e: any) { message.error(e.message || '删除失败') } }
async function handleSave() {
await formRef.value?.validate()
saveLoading.value = true
try {
const payload: AppResource = { resourceType: 'domain', name: form.domain, domain: form.domain.trim().toLowerCase(), registrar: form.registrar, icp: form.icp, icpNo: form.icpNo, sslResourceId: form.sslResourceId, sslBound: !!form.sslResourceId, appId: form.appId ? Number(form.appId) : undefined, expireAt: form.expireAt ? dayjs(form.expireAt).format('YYYY-MM-DD') : undefined, remark: form.remark }
if (editRecord.value) { payload.resourceId = editRecord.value.resourceId; await updateAppResource(payload); message.success('保存成功') }
else { await addAppResource(payload); message.success('添加成功') }
showAdd.value = false; resetForm(); loadList()
} catch (e: any) { message.error(e.message || '操作失败') } finally { saveLoading.value = false }
}
function handleBindSsl(item: AppResource) { currentDomain.value = item; bindSslForm.sslResourceId = undefined; showBindSsl.value = true }
async function confirmBindSsl() {
if (!bindSslForm.sslResourceId || !currentDomain.value) { message.warning('请选择 SSL 证书'); return }
try {
const payload: AppResource = { resourceId: currentDomain.value.resourceId, resourceType: 'domain', domain: currentDomain.value.domain, sslResourceId: bindSslForm.sslResourceId, sslBound: true }
await updateAppResource(payload)
message.success('SSL 证书绑定成功')
showBindSsl.value = false
loadList()
} catch (e: any) { message.error(e.message || '绑定失败') }
}
function cancelBindSsl() { showBindSsl.value = false; bindSslForm.sslResourceId = undefined; currentDomain.value = null }
async function handleUnbindSsl(item: AppResource) {
Modal.confirm({ title: '确定要解绑 SSL 证书?', content: `域名「${item.domain}」将解除与证书「${item.sslCertName || '未知'}」的绑定`, okText: '确定解绑', okType: 'warning', cancelText: '取消', onOk: async () => { try { const payload: AppResource = { resourceId: item.resourceId, resourceType: 'domain', domain: item.domain, sslResourceId: undefined, sslBound: false }; await updateAppResource(payload); message.success('SSL 证书解绑成功'); loadList() } catch (e: any) { message.error(e.message || '解绑失败') } } })
}
async function handleViewSsl(item: AppResource) {
if (!item.sslResourceId) { message.warning('未绑定 SSL 证书'); return }
currentDomain.value = item
const cert = sslOptions.value.find(c => c.resourceId === item.sslResourceId)
if (cert) { currentSslCert.value = cert; showViewSsl.value = true }
else { message.warning('证书信息加载失败') }
}
function resetForm() { editRecord.value = null; formRef.value?.resetFields(); Object.assign(form, { domain: '', registrar: '', icp: false, icpNo: '', sslResourceId: undefined, appId: undefined, expireAt: null, remark: '' }) }
onMounted(() => { loadAppOptions(); loadSslOptions(); loadList() })
</script>
<style scoped>
.dev-page { padding: 24px; max-width: 1200px; margin: 0 auto; }
.page-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.toolbar-left { display: flex; align-items: center; gap: 12px; }
.page-title { margin: 0; font-size: 20px; font-weight: 600; }
.toolbar-right { display: flex; align-items: center; gap: 12px; }
.notice-bar { display: flex; align-items: center; gap: 8px; background: #e6f4ff; border: 1px solid #91caff; border-radius: 6px; padding: 8px 14px; margin-bottom: 16px; font-size: 13px; color: #1677ff; }
.notice-icon { font-size: 15px; }
.card-loading, .card-empty { display: flex; align-items: center; justify-content: center; min-height: 300px; }
.domain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
.domain-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 10px; padding: 16px 20px; transition: all 0.2s; display: flex; flex-direction: column; gap: 12px; }
.domain-card:hover { border-color: #91caff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); }
.card-expiring { border-color: #ffa39e; background: #fff2f0; }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; }
.card-header-left { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
.domain-name { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #1a1a1a; cursor: pointer; padding: 2px 6px; border-radius: 4px; transition: background 0.15s; }
.domain-name:hover { background: #e6f4ff; color: #1677ff; }
.domain-text { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; }
.copy-icon { font-size: 12px; opacity: 0; transition: opacity 0.2s; color: #1677ff; }
.domain-name:hover .copy-icon { opacity: 1; }
.domain-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.card-info { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #666; }
.info-row { display: flex; align-items: center; gap: 8px; }
.info-label { color: #999; width: 70px; flex-shrink: 0; }
.info-value { color: #333; flex: 1; }
.icp-value { cursor: pointer; padding: 1px 4px; border-radius: 4px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
.icp-value:hover { background: #e6f4ff; color: #1677ff; }
.copy-icon-small { font-size: 10px; opacity: 0.6; }
.text-muted { color: #999; }
.text-warning { color: #fa8c16; font-weight: 500; }
.text-danger { color: #ff4d4f; font-weight: 500; }
.card-actions { display: flex; gap: 8px; padding-top: 4px; border-top: 1px solid #f5f5f5; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 24px; padding-top: 16px; border-top: 1px solid #f0f0f0; }
.form-section-header { display: flex; align-items: center; gap: 8px; padding: 8px 0 4px; font-size: 14px; font-weight: 500; color: #333; border-top: 1px solid #f0f0f0; margin-top: 12px; }
.form-section-header:first-child { border-top: none; margin-top: 0; }
.form-section-hint { font-size: 12px; font-weight: 400; color: #999; margin-left: 8px; }
.ssl-info { display: flex; flex-direction: column; gap: 12px; }
.ssl-info-item { display: flex; align-items: center; gap: 12px; }
.ssl-info-label { color: #999; width: 80px; flex-shrink: 0; font-size: 13px; }
.ssl-info-value { color: #333; flex: 1; font-size: 14px; }
</style>