173 lines
11 KiB
JavaScript
173 lines
11 KiB
JavaScript
/* 域名视图:域名列表 + 子域名管理 + 站点 SSL 状态
|
||
* 交互:每行可展开「子域名」面板;子域名可添加/编辑/删除、一键探测 SSL 证书;
|
||
* 域名行显示该域名下最近的证书到期徽章。
|
||
*/
|
||
const DomainsView = {
|
||
template: `
|
||
<div class="space-y-3">
|
||
<!-- 说明条 -->
|
||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||
🌐 域名资产下管理子域名与站点 SSL 证书:展开任意域名添加子域名(如 www/api),点「探测SSL」即可监控该站点证书到期情况。
|
||
</div>
|
||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-x-auto">
|
||
<table class="w-full text-sm min-w-[820px]">
|
||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||
<tr>
|
||
<th class="px-4 py-2.5 font-medium">域名</th>
|
||
<th class="px-4 py-2.5 font-medium">注册商</th>
|
||
<th class="px-4 py-2.5 font-medium">DNS</th>
|
||
<th class="px-4 py-2.5 font-medium">到期</th>
|
||
<th class="px-4 py-2.5 font-medium">站点SSL</th>
|
||
<th class="px-4 py-2.5 font-medium">使用中</th>
|
||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||
<tr v-if="!domains.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
|
||
<template v-for="a in domains" :key="a.id">
|
||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
|
||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
|
||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
|
||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
|
||
<td class="px-4 py-2.5">
|
||
<span v-if="sslByAsset[a.id]" class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" :class="certBadge(sslByAsset[a.id].days_to_expiry)" :title="sslByAsset[a.id].issuer || ''">
|
||
{{ sslByAsset[a.id].hostname }} · {{ certText(sslByAsset[a.id]) }}
|
||
</span>
|
||
<span v-else class="text-xs text-slate-400">—</span>
|
||
</td>
|
||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
|
||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
|
||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
|
||
</td>
|
||
</tr>
|
||
<!-- 子域名展开区 -->
|
||
<tr v-if="expanded[a.id]">
|
||
<td colspan="7" class="px-4 py-3 bg-slate-50/60 dark:bg-slate-800/20">
|
||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">子域名({{ domainName(a) }})</span>
|
||
<button @click="resetForm(a)" class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white">+ 新增子域名</button>
|
||
<button @click="probeSsl(domainName(a), a)" class="text-xs px-2 py-1 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400">🔒 探测根域名SSL</button>
|
||
<span class="ml-auto text-xs text-slate-400">共 {{ (subsByAsset[a.id]||[]).length }} 条</span>
|
||
</div>
|
||
|
||
<div v-if="(subsByAsset[a.id]||[]).length" class="space-y-1 mb-2">
|
||
<div v-for="s in subsByAsset[a.id]||[]" :key="s.id" class="flex flex-wrap items-center gap-2 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 px-3 py-2 text-xs">
|
||
<span class="font-mono font-medium text-slate-700 dark:text-slate-200">{{ fullHost(s, a) }}</span>
|
||
<span v-if="s.record_type" class="px-1.5 py-0.5 rounded bg-slate-500/10 text-slate-500">{{ s.record_type }}</span>
|
||
<span v-if="s.record_value" class="text-slate-400 truncate max-w-[180px]" :title="s.record_value">{{ s.record_value }}</span>
|
||
<span :class="s.is_active ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ s.is_active ? '启用' : '停用' }}</span>
|
||
<span v-if="s.note" class="text-slate-400 truncate max-w-[120px]" :title="s.note">{{ s.note }}</span>
|
||
<span class="ml-auto flex gap-3">
|
||
<button @click="probeSsl(fullHost(s, a), a)" class="text-violet-600 dark:text-violet-400 hover:underline">🔒 探测SSL</button>
|
||
<button @click="startEdit(a, s)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||
<button @click="delSub(s)" class="text-red-600 dark:text-red-400 hover:underline">删除</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-xs text-slate-400 mb-2">暂无子域名,点「+ 新增子域名」开始管理</div>
|
||
|
||
<!-- 新增/编辑表单 -->
|
||
<form v-if="forms[a.id]" @submit.prevent="saveSub(a)" class="flex flex-wrap gap-2 items-end bg-white dark:bg-slate-900 rounded-lg border border-blue-200 dark:border-blue-900/50 px-3 py-2">
|
||
<label class="text-xs text-slate-500">主机名
|
||
<input v-model="forms[a.id].host" placeholder="www / api(@=根域名)" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-28 mt-0.5">
|
||
</label>
|
||
<label class="text-xs text-slate-500">类型
|
||
<select v-model="forms[a.id].record_type" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 mt-0.5">
|
||
<option v-for="t in ['A','CNAME','AAAA','MX','TXT']" :key="t" :value="t">{{ t }}</option>
|
||
</select>
|
||
</label>
|
||
<label class="text-xs text-slate-500 flex-1 min-w-[130px]">记录值
|
||
<input v-model="forms[a.id].record_value" placeholder="IP 或目标域名" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-full mt-0.5">
|
||
</label>
|
||
<label class="text-xs text-slate-500">备注
|
||
<input v-model="forms[a.id].note" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-24 mt-0.5">
|
||
</label>
|
||
<label class="text-xs text-slate-500 flex items-center gap-1 pb-1.5"><input type="checkbox" v-model="forms[a.id].is_active" class="accent-blue-600">启用</label>
|
||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white">{{ forms[a.id].editing ? '保存' : '添加' }}</button>
|
||
<button type="button" @click="resetForm(a)" class="text-sm px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-500">取消</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`,
|
||
setup() {
|
||
const domains = Vue.computed(() => store.assets.filter(a => a.asset_type === 'domain' && a.domain_detail));
|
||
const expanded = Vue.reactive({}); // asset_id -> 是否展开子域名面板
|
||
const forms = Vue.reactive({}); // asset_id -> 新增/编辑表单
|
||
|
||
const subsByAsset = Vue.computed(() => {
|
||
const m = {};
|
||
for (const s of store.subdomains) { (m[s.asset_id] = m[s.asset_id] || []).push(s); }
|
||
return m;
|
||
});
|
||
// 每个域名资产下最近到期的一条证书(用于主行徽章)
|
||
const sslByAsset = Vue.computed(() => {
|
||
const m = {};
|
||
for (const c of store.siteCerts) {
|
||
if (!c.asset_id) continue;
|
||
if (!m[c.asset_id] || (c.days_to_expiry ?? 9999) < (m[c.asset_id].days_to_expiry ?? 9999)) m[c.asset_id] = c;
|
||
}
|
||
return m;
|
||
});
|
||
|
||
function domainName(a) { return a.domain_detail.domain_name; }
|
||
function fullHost(s, a) { return s.host === '@' ? domainName(a) : s.host + '.' + domainName(a); }
|
||
function toggle(a) {
|
||
expanded[a.id] = !expanded[a.id];
|
||
if (expanded[a.id] && !forms[a.id]) resetForm(a);
|
||
}
|
||
function resetForm(a) {
|
||
forms[a.id] = { host: '', record_type: 'A', record_value: '', note: '', is_active: true, editing: null };
|
||
}
|
||
function startEdit(a, s) {
|
||
expanded[a.id] = true;
|
||
forms[a.id] = { host: s.host, record_type: s.record_type || 'A', record_value: s.record_value || '', note: s.note || '', is_active: !!s.is_active, editing: s.id };
|
||
}
|
||
async function saveSub(a) {
|
||
const f = forms[a.id];
|
||
if (!f.host.trim()) { alert('请填写主机名'); return; }
|
||
const payload = { host: f.host.trim(), record_type: f.record_type, record_value: f.record_value, note: f.note, is_active: !!f.is_active };
|
||
try {
|
||
if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
|
||
else await Api.post('/subdomains', { ...payload, asset_id: a.id });
|
||
await loadSubdomains();
|
||
resetForm(a);
|
||
} catch (e) { alert('保存失败:' + e.message); }
|
||
}
|
||
async function delSub(s) {
|
||
if (!confirm('删除子域名「' + s.host + '」?')) return;
|
||
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
|
||
catch (e) { alert('删除失败:' + e.message); }
|
||
}
|
||
async function probeSsl(defaultHost, a) {
|
||
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
|
||
if (!target) return;
|
||
try {
|
||
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
|
||
await loadSiteCerts();
|
||
alert('已探测:' + target);
|
||
} catch (e) { alert('探测失败:' + e.message); }
|
||
}
|
||
function certText(c) {
|
||
const d = c.days_to_expiry;
|
||
if (d === null || d === undefined) return '未知';
|
||
if (d < 0) return '已过期' + Math.abs(d) + '天';
|
||
if (d === 0) return '今天到期';
|
||
return d + '天';
|
||
}
|
||
function certBadge(d) {
|
||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||
return 'bg-slate-500/10 text-slate-500';
|
||
}
|
||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
|
||
},
|
||
};
|