388 lines
24 KiB
Vue
388 lines
24 KiB
Vue
<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> |