评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
171 lines
12 KiB
JavaScript
171 lines
12 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&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</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 v-if="canDns(a)" @click="openDnsPanel(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs" title="直接管理云端 DNS 解析记录">DNS</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) {
|
||
// 统一走 Fmt.countdownText:不再自己维护一份,也避免负数直出「-3天」
|
||
return Fmt.countdownText(c.days_to_expiry, { unit: '', empty: '未知' });
|
||
}
|
||
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, canDns: assetCanDns, openDnsPanel: openDns };
|
||
},
|
||
};
|