feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 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
This commit is contained in:
@@ -594,3 +594,206 @@ const CredentialModal = {
|
||||
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= 云资源操作弹窗(启停/重启/续费) =================
|
||||
* 每个按钮都会真实改变云端资源,所以:
|
||||
* 1. 可用性完全由后端 /actions/{id}/capabilities 决定,前端不自作主张放开
|
||||
* 2. 点击后先出二次确认条,写清后果(续费会扣款、关机会中断服务)
|
||||
* 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */
|
||||
const ActionModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">⚡ 云资源操作</h3>
|
||||
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="text-sm font-medium truncate">{{ m.asset && m.asset.name }}</div>
|
||||
<div v-if="m.asset" class="text-xs text-slate-400">
|
||||
{{ m.asset.provider }}<span v-if="m.asset.external_id"> · {{ m.asset.external_id }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在查询该平台支持的操作…</div>
|
||||
|
||||
<div v-else-if="m.error && !m.caps" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<!-- 平台不可用:如实说明,不给可点按钮 -->
|
||||
<div v-else-if="m.caps && !m.caps.available" class="text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300">
|
||||
<div class="font-medium mb-1">该平台暂不支持远程操作</div>
|
||||
<div>{{ m.caps.reason || '适配器未实现对应能力,请到云厂商控制台操作。' }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="m.caps">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button v-for="a in actions" :key="a.key"
|
||||
@click="ask(a.key)"
|
||||
:disabled="!m.caps.actions[a.key] || !!m.confirm"
|
||||
class="px-3 py-2.5 rounded-lg border text-sm transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:class="a.cls">
|
||||
{{ a.icon }} {{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 二次确认条 -->
|
||||
<div v-if="m.confirm" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 px-3 py-3 space-y-2">
|
||||
<div class="text-xs font-medium text-amber-800 dark:text-amber-300">确认{{ label(m.confirm) }}「{{ m.asset.name }}」?</div>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ warn(m.confirm) }}</div>
|
||||
<div v-if="m.confirm === 'renew'" class="flex items-center gap-2 text-xs text-amber-800 dark:text-amber-300">
|
||||
续费周期
|
||||
<select v-model.number="m.periodMonths" class="px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-xs">
|
||||
<option v-for="n in [1,2,3,6,12,24,36]" :key="n" :value="n">{{ n }} 个月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="m.confirm=null" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
|
||||
<button @click="run(m.confirm)" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-xs disabled:opacity-50">
|
||||
{{ m.busy ? '下发中…' : '确认' + label(m.confirm) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<div class="text-[11px] text-slate-400 leading-relaxed">
|
||||
操作通过账号上配置的 API 凭证下发到云端,每次执行都会记入事件日志。
|
||||
平台能力:{{ capsText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = actionModal;
|
||||
const actions = [
|
||||
{ key: 'start', label: '开机', icon: '▶', cls: 'border-emerald-300 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20' },
|
||||
{ key: 'stop', label: '关机', icon: '⏻', cls: 'border-red-300 dark:border-red-800 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20' },
|
||||
{ key: 'reboot', label: '重启', icon: '⟳', cls: 'border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20' },
|
||||
{ key: 'renew', label: '续费', icon: '💳', cls: 'border-blue-300 dark:border-blue-800 text-blue-700 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20' },
|
||||
];
|
||||
const capsText = Vue.computed(() => {
|
||||
if (!m.caps || !m.caps.sdk_type || !m.caps.sdk_type.length) return '未知';
|
||||
return m.caps.sdk_type.join(' / ');
|
||||
});
|
||||
return {
|
||||
m, actions, capsText,
|
||||
label: (k) => ACTION_LABELS[k] || k,
|
||||
warn: (k) => ACTION_WARNS[k] || '',
|
||||
ask: askAction, run: runAction, close: closeActions,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= DNS 解析记录管理弹窗 ================= */
|
||||
const DnsModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-2xl md:rounded-xl rounded-t-xl max-h-[92vh] flex flex-col">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">🌐 DNS 解析<span v-if="m.zoneId" class="text-slate-400 font-normal"> · {{ m.zoneId }}</span></h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<button v-if="!m.loading && !m.error" @click="newRecord" class="text-xs text-blue-600 dark:text-blue-400">+ 新增记录</button>
|
||||
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 overflow-y-auto space-y-3">
|
||||
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在读取解析记录…</div>
|
||||
|
||||
<div v-else-if="m.error && !m.records.length" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!m.records.length" class="text-xs text-slate-400 py-4 text-center">该域名下暂无解析记录。</p>
|
||||
|
||||
<!-- 记录列表:窄屏用卡片,宽屏用表格 -->
|
||||
<div v-else class="hidden md:block border border-slate-100 dark:border-slate-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-500 dark:text-slate-400">
|
||||
<tr><th class="text-left px-3 py-2 font-medium">名称</th><th class="text-left px-3 py-2 font-medium">类型</th><th class="text-left px-3 py-2 font-medium">记录值</th><th class="text-left px-3 py-2 font-medium">TTL</th><th class="text-right px-3 py-2 font-medium">操作</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-for="r in m.records" :key="r.record_id">
|
||||
<td class="px-3 py-2 break-all">
|
||||
{{ r.name }}
|
||||
<span v-if="r.comment" class="block text-[10px] text-slate-400">{{ r.comment }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">{{ r.type }}</span></td>
|
||||
<td class="px-3 py-2 break-all text-slate-600 dark:text-slate-300">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500" title="已走 Cloudflare 代理">☁</span></td>
|
||||
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">
|
||||
{{ r.ttl || '自动' }}
|
||||
<span v-if="hasPriority(r)" class="ml-1.5" title="MX/SRV 优先级,数字越小越优先">优 {{ r.priority }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400 mr-3">编辑</button>
|
||||
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="m.records.length" class="md:hidden space-y-2">
|
||||
<div v-for="r in m.records" :key="r.record_id" class="border border-slate-100 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-300">{{ r.type }}</span>
|
||||
<span class="text-xs truncate flex-1">{{ r.name }}</span>
|
||||
<span v-if="hasPriority(r)" class="text-[10px] text-slate-400">优 {{ r.priority }}</span>
|
||||
<span class="text-[10px] text-slate-400">TTL {{ r.ttl || '自动' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-600 dark:text-slate-300 break-all mt-1">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500">☁</span></div>
|
||||
<div v-if="r.comment" class="text-[10px] text-slate-400 mt-0.5">{{ r.comment }}</div>
|
||||
<div class="flex gap-4 justify-end mt-1 text-xs">
|
||||
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑表单 -->
|
||||
<div v-if="m.editing" class="border border-blue-200 dark:border-blue-900 rounded-lg px-3 py-3 space-y-2 bg-blue-50/40 dark:bg-blue-900/10">
|
||||
<div class="text-xs font-medium text-blue-700 dark:text-blue-300">{{ m.editing === 'new' ? '新增记录' : '编辑记录' }}</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="text-xs text-slate-500 col-span-2">名称
|
||||
<input v-model="m.form.name" placeholder="www.example.com 或 www" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">类型
|
||||
<select v-model="m.form.type" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
<option v-for="t in recordTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">TTL(秒)
|
||||
<input v-model.number="m.form.ttl" type="number" min="60" max="86400" placeholder="厂商默认" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 col-span-2">记录值
|
||||
<input v-model="m.form.content" placeholder="1.2.3.4 / target.example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label v-if="m.form.type === 'MX' || m.form.type === 'SRV'" class="text-xs text-slate-500">优先级
|
||||
<input v-model.number="m.form.priority" type="number" min="0" max="65535" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label v-if="isCloudflare" class="text-xs text-slate-500 flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" v-model="m.form.proxied"> 走 Cloudflare 代理
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="m.error" class="text-xs px-2 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="cancelEdit" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
|
||||
<button @click="save" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs disabled:opacity-50">{{ m.busy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = dnsModal;
|
||||
const isCloudflare = Vue.computed(() => m.provider === 'cloudflare' || m.provider === 'cloudflare-api');
|
||||
// 显式判 null:MX 优先级 0 是合法值且为 falsy,用 r.priority 直接当真值会漏显示
|
||||
const hasPriority = (r) => r.priority !== null && r.priority !== undefined && r.priority !== '';
|
||||
return {
|
||||
m, recordTypes: RECORD_TYPES, isCloudflare, hasPriority,
|
||||
newRecord: newDnsRecord, editRecord: editDnsRecord, cancelEdit: cancelDnsEdit,
|
||||
save: saveDnsRecord, del: deleteDnsRecord, close: closeDns,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user