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:
@@ -134,6 +134,15 @@ window.VpsFmt = {
|
||||
if (d === 0) return '今天到期';
|
||||
return d + (opts.unit === '' ? ' 天' : ' 天后');
|
||||
},
|
||||
/* 费用文案:cost 为 0 一律显示「未填」,不显示「0 USD」
|
||||
0 有两种来源——用户没填、或云端接口压根不返回价格(阿里云/腾讯云/AWS 的
|
||||
DescribeInstances 都没有价格字段)。显示成「0 USD」会被读成「这台不花钱」,
|
||||
而汇总处(订阅页筛选、月度合计)又把 0 当作「无费用」排除掉,两处口径不一致。
|
||||
统一走这里,让「没数据」和「真的免费」在界面上不再混为一谈。 */
|
||||
costText(cost, currency) {
|
||||
if (!cost) return '未填';
|
||||
return currency ? cost + ' ' + currency : String(cost);
|
||||
},
|
||||
checkBadge(s) {
|
||||
return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500';
|
||||
},
|
||||
|
||||
@@ -87,6 +87,8 @@ const AppRoot = {
|
||||
<account-modal></account-modal>
|
||||
<accounts-view-modal></accounts-view-modal>
|
||||
<credential-modal></credential-modal>
|
||||
<action-modal></action-modal>
|
||||
<dns-modal></dns-modal>
|
||||
|
||||
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
|
||||
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
|
||||
@@ -136,6 +138,8 @@ app.component('provider-modal', ProviderModal);
|
||||
app.component('account-modal', AccountModal);
|
||||
app.component('accounts-view-modal', AccountsViewModal);
|
||||
app.component('credential-modal', CredentialModal);
|
||||
app.component('action-modal', ActionModal);
|
||||
app.component('dns-modal', DnsModal);
|
||||
applyDark();
|
||||
initRouter();
|
||||
loadAll();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+178
-1
@@ -28,6 +28,10 @@ const store = reactive({
|
||||
assets: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果
|
||||
allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用)
|
||||
providers: [],
|
||||
// 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities}
|
||||
// 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」,
|
||||
// 而不是只看 Provider.sdk_type 有没有值就宣称支持
|
||||
adapters: { adapters: [], supported_types: [], capabilities: {} },
|
||||
accounts: [],
|
||||
subdomains: [],
|
||||
siteCerts: [],
|
||||
@@ -134,6 +138,7 @@ async function loadAssets() {
|
||||
store.assets = await Api.get('/assets?' + params.toString());
|
||||
}
|
||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||
async function loadAdapters() { store.adapters = await Api.get('/providers/adapters'); }
|
||||
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||||
async function loadCredentials() {
|
||||
const params = new URLSearchParams();
|
||||
@@ -147,7 +152,7 @@ async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs');
|
||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||
async function loadAll() {
|
||||
store.loading = true; store.error = '';
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||||
}
|
||||
|
||||
@@ -523,6 +528,178 @@ async function viewServer(a) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 云端能力查询 ----------------
|
||||
* 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()),
|
||||
* 是「适配器真的实现了这个方法」的事实源。前端一律以它为准,
|
||||
* 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type
|
||||
* 会让按钮可点,用户点了才收到 400。 */
|
||||
function sdkCaps(sdkType) {
|
||||
if (!sdkType) return {};
|
||||
return (store.adapters.capabilities || {})[sdkType] || {};
|
||||
}
|
||||
function providerSdkType(slug) {
|
||||
const p = store.providers.find(x => x.slug === slug || x.name === slug);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */
|
||||
function providerIsSupported(slug) {
|
||||
const t = providerSdkType(slug);
|
||||
return !!t && (store.adapters.supported_types || []).includes(t);
|
||||
}
|
||||
/** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */
|
||||
function capsOf(slug, cap) {
|
||||
return !!sdkCaps(providerSdkType(slug))[cap];
|
||||
}
|
||||
/** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */
|
||||
function assetSdkType(a) {
|
||||
if (!a) return null;
|
||||
const p = a.provider_id
|
||||
? store.providers.find(x => x.id === a.provider_id)
|
||||
: store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */
|
||||
function assetCan(a, cap) {
|
||||
return !!sdkCaps(assetSdkType(a))[cap];
|
||||
}
|
||||
const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance'];
|
||||
/** VPS 是否可远程操作(任一实例动作可用即可) */
|
||||
function assetCanControl(a) {
|
||||
return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c));
|
||||
}
|
||||
/** 域名是否可管 DNS 解析 */
|
||||
function assetCanDns(a) {
|
||||
return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records');
|
||||
}
|
||||
|
||||
/* ---------------- 云资源动作(启停/重启/续费) ----------------
|
||||
* 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。
|
||||
* 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */
|
||||
const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' };
|
||||
const ACTION_WARNS = {
|
||||
start: '将向云端下发开机指令。',
|
||||
stop: '将向云端下发关机指令,机器上运行的服务会立即中断。',
|
||||
reboot: '将向云端下发重启指令,机器会短暂不可用。',
|
||||
renew: '将向云端下发续费指令,会产生真实扣款。',
|
||||
};
|
||||
|
||||
const actionModal = reactive({
|
||||
show: false, asset: null, caps: null, loading: true, busy: false,
|
||||
confirm: null, // 待二次确认的动作名
|
||||
periodMonths: 1,
|
||||
error: '',
|
||||
});
|
||||
|
||||
function openActions(asset) {
|
||||
actionModal.asset = asset;
|
||||
actionModal.caps = null;
|
||||
actionModal.loading = true;
|
||||
actionModal.confirm = null;
|
||||
actionModal.periodMonths = 1;
|
||||
actionModal.error = '';
|
||||
actionModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/capabilities')
|
||||
.then(r => { actionModal.caps = r; })
|
||||
.catch(e => { actionModal.error = e.message; })
|
||||
.finally(() => { actionModal.loading = false; });
|
||||
}
|
||||
function closeActions() { actionModal.show = false; actionModal.confirm = null; }
|
||||
function askAction(name) { actionModal.confirm = name; }
|
||||
async function runAction(name) {
|
||||
if (actionModal.busy) return;
|
||||
actionModal.busy = true; actionModal.error = '';
|
||||
try {
|
||||
const body = { action: name };
|
||||
if (name === 'renew') body.period_months = actionModal.periodMonths;
|
||||
const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body);
|
||||
if (r.ok) {
|
||||
showToast('✓ ' + ACTION_LABELS[name] + '指令已下发');
|
||||
actionModal.confirm = null;
|
||||
await loadAllAssets();
|
||||
if (store.view === 'assets') await loadAssets();
|
||||
} else {
|
||||
actionModal.error = r.message || '云端拒绝了该操作';
|
||||
}
|
||||
} catch (e) { actionModal.error = e.message; }
|
||||
finally { actionModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- DNS 记录管理 ---------------- */
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'];
|
||||
const dnsModal = reactive({
|
||||
show: false, asset: null, zoneId: '', provider: '',
|
||||
records: [], loading: true, busy: false, error: '',
|
||||
editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑
|
||||
form: null,
|
||||
});
|
||||
|
||||
function openDns(asset) {
|
||||
dnsModal.asset = asset;
|
||||
dnsModal.records = [];
|
||||
dnsModal.loading = true;
|
||||
dnsModal.error = '';
|
||||
dnsModal.editing = null;
|
||||
// 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值,
|
||||
// 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。
|
||||
dnsModal.provider = asset.provider || '';
|
||||
dnsModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function closeDns() { dnsModal.show = false; dnsModal.editing = null; }
|
||||
function reloadDns() {
|
||||
dnsModal.loading = true; dnsModal.error = '';
|
||||
Api.get('/actions/' + dnsModal.asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function newDnsRecord() {
|
||||
dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' };
|
||||
dnsModal.editing = 'new';
|
||||
}
|
||||
function editDnsRecord(r) {
|
||||
dnsModal.form = {
|
||||
name: r.name || '', type: r.type || 'A', content: r.content || '',
|
||||
ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '',
|
||||
};
|
||||
dnsModal.editing = r;
|
||||
}
|
||||
function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; }
|
||||
async function saveDnsRecord() {
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
const isNew = dnsModal.editing === 'new';
|
||||
const body = { ...dnsModal.form };
|
||||
// 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发
|
||||
if (body.ttl === '' || body.ttl === null) delete body.ttl;
|
||||
if (body.priority === '' || body.priority === null) delete body.priority;
|
||||
if (body.proxied === null || body.proxied === '') delete body.proxied;
|
||||
if (!body.comment) delete body.comment;
|
||||
try {
|
||||
const r = isNew
|
||||
? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body)
|
||||
: await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body);
|
||||
if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); }
|
||||
else dnsModal.error = r.message || '操作被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
async function deleteDnsRecord(r) {
|
||||
// 删解析会立刻影响线上访问,必须二次确认而不是直接删
|
||||
if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return;
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
try {
|
||||
const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id));
|
||||
if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); }
|
||||
else dnsModal.error = res.message || '删除被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||||
|
||||
@@ -31,6 +31,7 @@ const AssetsView = {
|
||||
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select>
|
||||
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
|
||||
<button v-if="store.assetTab==='' || store.assetTab==='vps'" @click="syncAllAssets" class="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20" title="拉取所有已配 API 凭证账号下的云端 VPS 与域名">同步云端资产</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -55,10 +56,11 @@ const AssetsView = {
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.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 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
|
||||
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400 hover:underline mr-3 text-xs" title="远程开机/关机/重启/续费">云操作</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
@@ -81,11 +83,12 @@ const AssetsView = {
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
|
||||
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400">云操作</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
@@ -156,6 +159,23 @@ const AssetsView = {
|
||||
await loadAssets();
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
|
||||
async function syncAllAssets() {
|
||||
if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
|
||||
try {
|
||||
const r = await Api.post('/sync/assets', {});
|
||||
const lines = [
|
||||
'同步完成:成功 ' + r.success + ',失败 ' + r.failed,
|
||||
'新增 ' + r.created + ',更新 ' + r.updated,
|
||||
];
|
||||
// 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来,
|
||||
// 否则用户以为同步过了、实际一条都没拉
|
||||
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
|
||||
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
|
||||
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
|
||||
alert(lines.join('\n'));
|
||||
await Promise.all([loadAssets(), loadOverview()]);
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ const DashboardView = {
|
||||
<button type="button" @click="goYearCost" title="点击查看付费资产汇总" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
|
||||
<div class="text-xs text-slate-500">本年支出</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
|
||||
<!-- 合计只统计填了费用的资产,缺口必须说出来:否则这个数看着像准数、实际是下限 -->
|
||||
<div v-if="store.overview.year_cost_missing" class="text-[11px] mt-1 text-amber-600 dark:text-amber-400">另有 {{ store.overview.year_cost_missing }} 项未填费用,实际更高</div>
|
||||
</button>
|
||||
<button type="button" @click="goAbnormal" title="点击查看已停止/已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-700 transition">
|
||||
<div class="text-xs text-slate-500">异常状态</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ const DomainsView = {
|
||||
<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>
|
||||
@@ -164,6 +165,6 @@ const DomainsView = {
|
||||
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 };
|
||||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit, canDns: assetCanDns, openDnsPanel: openDns };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ const MonitorView = {
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
@@ -107,7 +107,7 @@ const MonitorView = {
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span class="text-slate-500">{{ a.expiry_date }}</span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
|
||||
@@ -25,7 +25,16 @@ const ProvidersView = {
|
||||
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-2 space-y-1">
|
||||
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
|
||||
<!-- API 对接状态如实展示:能力矩阵来自后端 /providers/adapters,
|
||||
即「适配器真的实现了哪些方法」,不是拿 sdk_type 有没有值充数 -->
|
||||
<div v-if="isSupported(p)" class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">✓ API 已对接</span>
|
||||
<span v-for="c in capsSummary(p)" :key="c" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">{{ c }}</span>
|
||||
</div>
|
||||
<div v-else-if="p.sdk_type" class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-red-500/10 text-red-600 dark:text-red-400" :title="'声明的 ' + p.sdk_type + ' 没有对应适配器实现'">
|
||||
⚠ 已声明 {{ p.sdk_type }},但无对应适配器
|
||||
</div>
|
||||
<div v-else class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400">未对接 API(仅台账登记)</div>
|
||||
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
|
||||
@@ -79,10 +88,29 @@ const ProvidersView = {
|
||||
function serviceList(p) {
|
||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// API 对接状态:以适配器注册表为准,不看 sdk_type 有没有值
|
||||
function isSupported(p) {
|
||||
return !!p.sdk_type && (store.adapters.supported_types || []).includes(p.sdk_type);
|
||||
}
|
||||
// 能力摘要:把 capabilities 里为 true 的方法归并成用户看得懂的标签
|
||||
const CAPS_LABELS = [
|
||||
['list_vps', 'VPS同步'],
|
||||
['list_domains', '域名同步'],
|
||||
['get_account', '余额'],
|
||||
['start_instance', '开关机'],
|
||||
['reboot_instance', '重启'],
|
||||
['renew_instance', '续费'],
|
||||
['list_dns_records', 'DNS查询'],
|
||||
['create_dns_record', 'DNS写入'],
|
||||
];
|
||||
function capsSummary(p) {
|
||||
const caps = sdkCaps(p.sdk_type);
|
||||
return CAPS_LABELS.filter(([k]) => caps[k]).map(([, label]) => label);
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
|
||||
return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const SubscriptionsView = {
|
||||
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></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 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
|
||||
Reference in New Issue
Block a user