Files
vps-manager/static/js/views/domains.js
T

173 lines
11 KiB
JavaScript
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.
/* 域名视图:域名列表 + 子域名管理 + 站点 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 };
},
};